Bladeren bron

Initial commit

Zed 7 jaren geleden
commit
cea5cc0523
14 gewijzigde bestanden met toevoegingen van 1370 en 0 verwijderingen
  1. 2 0
      .gitignore
  2. 14 0
      nitter.nimble
  3. 561 0
      public/style.css
  4. 105 0
      src/api.nim
  5. 74 0
      src/cache.nim
  6. 89 0
      src/formatters.nim
  7. 75 0
      src/nitter.nim
  8. 100 0
      src/parser.nim
  9. 40 0
      src/types.nim
  10. 23 0
      src/utils.nim
  11. 38 0
      src/views/conversation.nim
  12. 50 0
      src/views/general.nim
  13. 99 0
      src/views/tweet.nim
  14. 100 0
      src/views/user.nim

+ 2 - 0
.gitignore

@@ -0,0 +1,2 @@
+nitter
+*.html

+ 14 - 0
nitter.nimble

@@ -0,0 +1,14 @@
+# Package
+
+version       = "0.1.0"
+author        = "zedeus"
+description   = "An alternative front-end for Twitter"
+license       = "AGPL-3.0"
+srcDir        = "src"
+bin           = @["nitter"]
+
+
+# Dependencies
+
+requires "nim >= 0.19.9"
+requires "regex", "nimquery", "nimcrypto", "norm", "jester"

+ 561 - 0
public/style.css

@@ -0,0 +1,561 @@
+body {
+    background-color: #121212;
+    color: #f8f8f2;
+    margin: 0;
+    font-family: "Helvetica Neue",Helvetica,Arial,sans-serif;
+}
+
+#tweets {
+    background-color: #161616;
+}
+
+#user {
+    background-color: #242424;
+    width: 100%;
+    padding: 5pt;
+}
+
+#user > h1 {
+    color: #ffffff;
+}
+
+h1 {
+    margin: 0;
+    display: inline;
+}
+
+h2 {
+    margin: 0;
+    font-weight: normal;
+}
+
+h3 {
+    margin-bottom: 0;
+    font-weight: normal;
+}
+
+h4 {
+    margin: 0;
+}
+
+a {
+    color: #ff6c60;
+    text-decoration: none;
+}
+
+a:hover {
+    text-decoration: underline;
+}
+
+.status-el {
+    overflow-wrap: break-word;
+    word-wrap: break-word;
+    word-break: break-word;
+    border-left-width: 0;
+    min-width: 0;
+    padding: .75em;
+    display: flex;
+}
+
+.timeline-tweet {
+    border-bottom: 1px solid #3e3e35;
+}
+
+.status-body {
+    flex: 1;
+    min-width: 0;
+}
+
+.media-heading a {
+    display: inline-block;
+    word-break: break-all;
+}
+
+.status-el .media-heading {
+    padding: 0;
+    vertical-align: bottom;
+    flex-basis: 100%;
+    margin-bottom: .2em;
+}
+
+.status-el .media-heading .heading-name-row {
+    padding: 0;
+    display: flex;
+    justify-content: space-between;
+    line-height: 18px;
+}
+
+.status-el .media-heading .heading-name-row .name-and-account-name {
+    display: flex;
+    min-width: 0;
+}
+
+.status-el .media-heading .heading-name-row .username {
+    flex-shrink: 1;
+    margin-right: .4em;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+.status-el .username {
+    white-space: nowrap;
+    font-size: 14px;
+    overflow: hidden;
+    flex-shrink: 0;
+    font-weight: 700;
+}
+
+.username {
+    color: #f8f8f2;
+}
+
+.status-el .media-heading .heading-right {
+    display: flex;
+    flex-shrink: 0;
+}
+
+.status-el .media-heading .heading-name-row .account-name {
+    min-width: 1.6em;
+    margin-right: .4em;
+    flex: 1 1 0;
+    white-space: nowrap;
+    word-wrap: normal;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+.status-el .media-heading a {
+    display: inline-block;
+    word-break: break-all;
+}
+
+.status-el .status-content {
+    font-family: sans-serif;
+    line-height: 1.4em;
+}
+
+.status-el .media-body {
+    flex: 1;
+    padding: 0;
+}
+
+.container, .item {
+    display: flex;
+}
+
+.container {
+    flex-wrap: wrap;
+    margin: 0;
+}
+
+#content {
+    box-sizing: border-box;
+    padding-top: 50px;
+    margin: auto;
+    min-height: 100vh;
+    background-color: rgba(0,0,0,.15);
+}
+
+nav {
+    z-index: 1000;
+    background-color: #1f1f1f;
+    color: hsla(240,1%,73%,.5);
+    box-shadow: 0 0 4px rgba(0,0,0,.6);
+}
+
+.nav-bar {
+    padding: 0;
+    width: 100%;
+    align-items: center;
+    position: fixed;
+    height: 50px;
+}
+
+.nav-bar .inner-nav {
+    margin: auto;
+    box-sizing: border-box;
+    padding-left: 10px;
+    padding-right: 10px;
+    display: flex;
+    align-items: center;
+    height: 50px;
+}
+
+.item {
+    flex: 1;
+    line-height: 50px;
+    height: 50px;
+    overflow: hidden;
+    flex-wrap: wrap;
+}
+
+.attachments {
+    margin-top: .5em;
+    display: flex;
+    flex-direction: row;
+    width: 100%;
+    max-height: 600px;
+    border-radius: 7px;
+    overflow: hidden;
+    flex-flow: column;
+}
+
+.gallery-row {
+    display: flex;
+    flex-direction: row;
+    flex-wrap: nowrap;
+    align-items: center;
+    overflow: hidden;
+    flex-grow: 1;
+    max-height: 379.5px;
+    max-width: 506px;
+}
+
+.gallery-row .attachment, .gallery-row .attachments {
+    margin: 0 .25em 0 0;
+    flex-grow: 1;
+    box-sizing: border-box;
+    min-width: 2em;
+}
+
+.gallery-row .attachment:last-child, .gallery-row .attachments:last-child {
+    margin: 0;
+}
+
+.attachments .attachment {
+    position: relative;
+    line-height: 0;
+    border-color: #222;
+    overflow: hidden;
+}
+
+.gallery-row .image-attachment {
+    width: 100%;
+}
+
+.attachments .image-attachment {
+    width: 100%;
+}
+
+.still-image {
+    max-height: 379.5px;
+    max-width: 506px;
+    justify-content: center;
+}
+
+.still-image img {
+    object-fit: cover;
+    max-width: 506px;
+    max-height: 379.5px;
+    border-color: #222;
+    flex-basis: 300px;
+}
+
+.status-body {
+    margin-left: 58px;
+}
+
+.avatar {
+    float: left;
+    margin-top: 3px;
+    margin-left: -58px;
+    position: absolute;
+    width: 48px;
+    height: 48px;
+    border-radius: 50%;
+}
+
+.retweet, .pinned, .tweet-stats {
+    align-content: center;
+    color: hsla(240,1%,73%,.7);
+    display: flex;
+    flex-shrink: 0;
+    flex-wrap: wrap;
+    font-size: 14px;
+    font-weight: 600;
+    line-height: 22px;
+    margin: 0;
+    max-width: 85%;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+
+.tweet-stat {
+    padding-top: 5px;
+    padding-right: 8px;
+}
+
+.show-more {
+    text-align: center;
+    padding: .75em 0;
+    display: block;
+}
+
+.show-more.status-el {
+    border-bottom: 1px solid #3e3e35;
+}
+
+.show-more a {
+    background-color: #242424;
+    display: inline-block;
+    height: 2em;
+    padding: 0 2em;
+    line-height: 2em;
+}
+.show-more a:hover {
+    background-color: #282828;
+}
+
+.profile-tabs {
+    max-width: 846px;
+    margin: 0 auto;
+    float: none;
+    border-radius: 0;
+    position: relative;
+    width: auto;
+}
+
+.timeline-tab {
+    float: right;
+    padding: 0 4px;
+    box-sizing: border-box;
+    display: inline-block;
+    font-size: 14px;
+    margin: 0;
+    text-align: left;
+    vertical-align: top;
+    width: 70% !important;
+}
+
+.profile-tab {
+    padding: 0 4px;
+    box-sizing: border-box;
+    display: inline-block;
+    font-size: 14px;
+    margin: 0;
+    text-align: left;
+    vertical-align: top;
+    width: 30% !important;
+}
+
+.profile-banner {
+    padding: 0 4px;
+}
+
+.profile-banner img {
+    width: 100%;
+    padding-bottom: 3px;
+}
+
+.profile-banner-color {
+    width: 100%;
+    padding-bottom: 25%;
+    margin-bottom: 8px;
+}
+
+.profile-card {
+    float: left;
+    flex-wrap: wrap;
+    margin-top: 0;
+    background: #161616;
+    border-radius: 0 0 4px 4px;
+    padding: 12px;
+    position: relative;
+    display: flex;
+    justify-content: flex-start;
+}
+
+.profile-card-tabs {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    flex: 1 1 auto;
+}
+
+.profile-card-tabs-name {
+    padding-top: 0;
+    padding-bottom: 0;
+}
+
+.profile-card-name, .profile-card-username {
+    color: #f8f8f2;
+}
+
+.profile-card-name {
+    font-size: 16px;
+    word-wrap: break-word;
+    text-shadow: none;
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+}
+
+.profile-card-username {
+    font-size: 14px;
+}
+
+.profile-card-avatar {
+    display: block;
+    width: 100%;
+    padding-bottom: 16px;
+    margin-right: 4px;
+}
+
+.profile-card-avatar img {
+    display: block;
+    width: calc(100% - 4px);
+    height: 100%;
+    margin: 0;
+    border: 4px solid #282828;
+    background: #040404;
+}
+
+.profile-card-extra {
+    display: block;
+    flex: 100%;
+    margin-top: 4px;
+ }
+
+.profile-bio {
+    border-radius: 0;
+    box-shadow: none;
+    background: transparent;
+    overflow: hidden;
+    margin-right: -5px
+}
+
+.profile-description {
+    font-size: 14px;
+    font-weight: 400;
+    overflow: hidden;
+    word-break: normal;
+    word-wrap: break-word;
+}
+
+.conversation {
+    max-width: 580px;
+    margin: 0 auto;
+    float: none;
+    border-radius: 0;
+    position: relative;
+    width: auto;
+    background-color: #0f0f0f !important;
+}
+
+.main-thread {
+    margin-bottom: 20px;
+    background-color: #161616;
+}
+
+.main-tweet .status-content {
+    font-size: 22px;
+    line-height: 30px;
+    letter-spacing: .01em;
+}
+
+.thread {
+    background-color: #161616;
+    margin-bottom: 10px;
+}
+
+.panel {
+    margin: auto;
+    font-size: 130%;
+}
+
+.error-panel {
+    background-color: #420a05 !important;
+}
+
+.error-panel, .search-panel > form {
+    padding: 12px;
+    border-radius: 4px;
+    display: flex;
+    background: #222222;
+    box-shadow: 0 0 15px rgba(0,0,0,.2);
+    margin: auto;
+    margin-top: -50px;
+}
+
+.search-panel > form > button {
+    font-size: 14px;
+    line-height: 24px;
+    display: block;
+    border: 0;
+    border-radius: 4px;
+    background: #2f2f2f;
+    color: #f8f8f2;
+    outline: 0;
+    text-decoration: none;
+    text-align: center;
+    box-sizing: border-box;
+    cursor: pointer;
+    font-weight: bold;
+    width: 37px;
+    height: 32px;
+    padding: 0;
+}
+
+.search-panel > form > input {
+    box-sizing: border-box;
+    font-size: 16px;
+    display: block;
+    width: 100%;
+    outline: 0;
+    font-family: inherit;
+    background: #131419;
+    color: #f8f8f2;
+    border: 1px solid #0a0b0e;
+    border-radius: 4px;
+    padding: 4px;
+    margin-right: 8px;
+}
+
+.profile-card-extra-links {
+    margin-top: 8px;
+    font-size: 14px;
+}
+
+.profile-statlist {
+    vertical-align: bottom;
+    table-layout: fixed;
+    box-sizing: border-box;
+    display: flex;
+    margin: 0;
+    padding: 0;
+    width: 100%;
+}
+
+.profile-statlist > li {
+    display: table-cell;
+    text-align: center;
+}
+
+.profile-statlist .tweets {
+    flex-shrink: 2;
+}
+
+.profile-statlist .followers {
+    flex-grow: 2;
+}
+
+.profile-statlist .following {
+    flex-shrink: 1.5;
+}
+
+.profile-stat-header {
+    font-weight: bold;
+}
+
+.timeline-protected {
+    max-width: 550px;
+    margin: 0 auto;
+    padding: 6px 0px;
+}
+
+.timeline-protected-header {
+    color: #d0564c;
+    font-size: 21px;
+    font-weight: bold;
+}

+ 105 - 0
src/api.nim

@@ -0,0 +1,105 @@
+import httpclient, asyncdispatch, htmlparser, times
+import sequtils, strutils, strformat, json, xmltree, uri
+import nimquery, regex
+
+import ./types, ./parser
+
+const base = parseUri("https://twitter.com/")
+const agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36"
+
+const timelineUrl = "i/profiles/show/$1/timeline/tweets?include_available_features=1&include_entities=1&include_new_items_bar=true"
+const profileUrl = "i/profiles/popup"
+const tweetUrl = "i/status/"
+
+proc getProfile*(username: string): Future[Profile] {.async.} =
+  let client = newAsyncHttpClient()
+  defer: client.close()
+
+  client.headers = newHttpHeaders({
+    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9",
+    "Referer": $(base / username),
+    "User-Agent": agent,
+    "X-Twitter-Active-User": "yes",
+    "X-Requested-With": "XMLHttpRequest",
+    "Accept-Language": "en-US,en;q=0.9"
+  })
+
+  let params = {
+    "screen_name": username,
+    "wants_hovercard": "true",
+    "_": $(epochTime().int)
+  }
+
+  let url = base / profileUrl ? params
+  var resp = ""
+
+  try:
+    resp = await client.getContent($url)
+  except:
+    return Profile()
+
+  let
+    json = parseJson(resp)["html"].str
+    html = parseHtml(json)
+
+  result = parseProfile(html)
+
+proc getTimeline*(username: string; after=""): Future[Tweets] {.async.} =
+  let client = newAsyncHttpClient()
+  defer: client.close()
+
+  client.headers = newHttpHeaders({
+    "Accept": "application/json, text/javascript, */*; q=0.01",
+    "Referer": $(base / username),
+    "User-Agent": agent,
+    "X-Twitter-Active-User": "yes",
+    "X-Requested-With": "XMLHttpRequest",
+    "Accept-Language": "en-US,en;q=0.9"
+  })
+
+  var url = timelineUrl % username
+  if after != "":
+    url &= "&max_position=" & after
+
+  var resp = ""
+  try:
+    resp = await client.getContent($(base / url))
+  except:
+    return
+
+  var json: string = ""
+  var html: XmlNode
+  json = parseJson(resp)["items_html"].str
+  html = parseHtml(json)
+
+  writeFile("epic.html", $html)
+
+  result = parseTweets(html)
+
+proc getTweet*(id: string): Future[Conversation] {.async.} =
+  let client = newAsyncHttpClient()
+  defer: client.close()
+
+  client.headers = newHttpHeaders({
+    "Accept": "application/json, text/javascript, */*; q=0.01",
+    "Referer": $base,
+    "User-Agent": agent,
+    "X-Twitter-Active-User": "yes",
+    "X-Requested-With": "XMLHttpRequest",
+    "Accept-Language": "en-US,en;q=0.9",
+    "pragma": "no-cache",
+    "x-previous-page-name": "profile"
+  })
+
+  let url = base / tweetUrl / id
+
+  var resp: string = ""
+  try:
+    resp = await client.getContent($url)
+  except:
+    return Conversation()
+
+  var html: XmlNode
+  html = parseHtml(resp)
+
+  result = parseConversation(html)

+ 74 - 0
src/cache.nim

@@ -0,0 +1,74 @@
+import sharedtables, times, hashes
+import types, api
+
+# var
+#   profileCache: SharedTable[int, Profile]
+#   profileCacheTime = initDuration(seconds=10)
+
+# profileCache.init()
+
+proc getCachedProfile*(username: string; force=false): Profile =
+  return getProfile(username)
+  # let index = username.hash
+
+  # try:
+  #   result = profileCache.mget(index)
+  #   # if force or getTime() - result.lastUpdated > profileCacheTime:
+  #   #   result = getProfile(username)
+  #   #   profileCache[username.hash] = deepCopy(result)
+  #   #   return
+  # except KeyError:
+  #   # result = getProfile(username)
+  #   # profileCache.add(username.hash, deepCopy(result))
+
+
+
+  #   var profile: Profile
+  #   profileCache.withKey(index) do (k: int, v: var Profile, pairExists: var bool):
+  #     v = getProfile(username)
+  #     profile = v
+  #     echo v
+  #     pairExists = true
+  #   echo profile.username
+  #   return profile
+
+  # profileCache.withValue(hash(username), value) do:
+  #   if getTime() - value.lastUpdated > profileCacheTime or force:
+  #     result = getProfile(username)
+  #     value = result
+  #   else:
+  #     result = value
+  # do:
+  #   result = getProfile(username)
+  #   value = result
+
+  # var profile: Profile
+
+  # profileCache.withKey(username.hash) do (k: int, v: var Profile, pairExists: var bool):
+  #   if pairExists and getTime() - v.lastUpdated < profileCacheTime and not force:
+  #     profile = deepCopy(v)
+  #     echo "cached"
+  #   else:
+  #     profile = getProfile(username)
+  #     v = deepCopy(profile)
+  #     pairExists = true
+  #     echo "fetched"
+
+  # return profile
+
+  # try:
+  #   result = profileCache.mget(username.hash)
+  #   if force or getTime() - result.lastUpdated > profileCacheTime:
+  #     result = getProfile(username)
+  #     profileCache[username.hash] = deepCopy(result)
+  #     return
+  # except KeyError:
+  #   result = getProfile(username)
+  #   profileCache.add(username.hash, deepCopy(result))
+
+  # if not result.isNil or force or
+  #   getTime() - result.lastUpdated > profileCacheTime:
+  #   result = getProfile(username)
+  #   profileCache[username] = result
+    # return
+

+ 89 - 0
src/formatters.nim

@@ -0,0 +1,89 @@
+import strutils, strformat, htmlgen, xmltree
+import regex
+
+import ./types, ./utils
+
+const
+  urlRegex = re"((https?|ftp)://(-\.)?([^\s/?\.#]+\.?)+(/[^\s]*)?)"
+  emailRegex = re"([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)"
+  usernameRegex = re"(^|[^\S\n]|\.)@([A-z0-9_]+)"
+  picRegex = re"pic.twitter.com/[^ ]+"
+  cardRegex = re"(https?://)?cards.twitter.com/[^ ]+"
+  ellipsisRegex = re" ?…"
+
+proc shortLink*(text: string; length=28): string =
+  result = text.replace(re"https?://(www.)?", "")
+  if result.len > length:
+    result = result[0 ..< length] & "…"
+
+proc toLink*(url, text: string; class="timeline-link"): string =
+  htmlgen.a(text, class=class, href=url)
+
+proc reUrlToLink*(m: RegexMatch; s: string): string =
+  let url = s[m.group(0)[0]]
+  toLink(url, " " & shortLink(url))
+
+proc reEmailToLink*(m: RegexMatch; s: string): string =
+  let url = s[m.group(0)[0]]
+  toLink("mailto://" & url, url)
+
+proc reUsernameToLink*(m: RegexMatch; s: string): string =
+  var
+    username = ""
+    pretext = ""
+
+  let
+    pre = m.group(0)
+    match = m.group(1)
+
+  username = s[match[0]]
+
+  if pre.len > 0:
+    pretext = s[pre[0]]
+
+  pretext & toLink("/" & username, "@" & username)
+
+proc linkifyText*(text: string): string =
+  result = text.replace("\n", "<br>")
+  result = result.replace(ellipsisRegex, "")
+  result = result.replace(usernameRegex, reUsernameToLink)
+  result = result.replace(emailRegex, reEmailToLink)
+  result = result.replace(urlRegex, reUrlToLink)
+
+proc stripTwitterUrls*(text: string): string =
+  result = text
+  result = result.replace(picRegex, "")
+  result = result.replace(cardRegex, "")
+  result = result.replace(ellipsisRegex, "")
+
+proc getUserpic*(userpic: string; style=""): string =
+  let pic = userpic.replace(re"_(normal|bigger|mini|200x200)(\.[A-z]+)$", "$2")
+  pic.replace(re"(\.[A-z]+)$", style & "$1")
+
+proc getUserpic*(profile: Profile; style=""): string =
+  getUserPic(profile.userpic, style)
+
+proc getGifSrc*(tweet: Tweet): string =
+  fmt"https://video.twimg.com/tweet_video/{tweet.gif}.mp4"
+
+proc getGifThumb*(tweet: Tweet): string =
+  fmt"https://pbs.twimg.com/tweet_video_thumb/{tweet.gif}.jpg"
+
+proc formatName(profile: Profile): string =
+  result = profile.fullname
+  if profile.verified:
+    result &= " 🔹"
+  elif profile.protected:
+    result &= " 🔒"
+  result = xmltree.escape(result)
+
+proc linkUser*(profile: Profile; h: string; username=true; class=""): string =
+  let text =
+    if username: "@" & profile.username
+    else: formatName(profile)
+
+  if h == "":
+    return htmlgen.a(text, href = &"/{profile.username}", class=class)
+
+  let element = &"<{h} class=\"{class}\">{text}</{h}>"
+  htmlgen.a(element, href = &"/{profile.username}")

+ 75 - 0
src/nitter.nim

@@ -0,0 +1,75 @@
+import asyncdispatch, httpclient, times, strutils, hashes, random, uri
+import jester, regex
+
+import api, utils, types
+import views/[user, general, conversation]
+
+proc showTimeline(name: string; num=""): Future[string] {.async.} =
+  let
+    username = name.strip(chars={'/'})
+    profileFut = getProfile(username)
+    tweetsFut = getTimeline(username, after=num)
+
+  let profile = await profileFut
+  if profile.username == "":
+    return ""
+
+  return renderMain(renderProfile(profile, await tweetsFut, num == ""))
+
+routes:
+  get "/":
+    resp renderMain(renderSearchPanel())
+
+  post "/search":
+    if @"query".len == 0:
+      resp Http404, showError("Please enter a username.")
+
+    redirect("/" & @"query")
+
+  get "/@name/?":
+    cond '.' notin @"name"
+    let timeline = await showTimeline(@"name", @"after")
+    if timeline == "":
+      resp Http404, showError("User \"" & @"name" & "\" not found")
+
+    resp timeline
+
+  get "/@name/status/@id":
+    cond '.' notin @"name"
+    let conversation = await getTweet(@"id")
+    if conversation.tweet.id == "":
+      resp Http404, showError("Tweet not found")
+
+    resp renderMain(renderConversation(conversation))
+
+  get "/pic/@sig/@url":
+    cond "http" in @"url"
+    cond "twimg" in @"url"
+    let url = decodeUrl(@"url")
+
+    if getHmac(url) != @"sig":
+      resp showError("Failed to verify signature")
+
+    let
+      client = newAsyncHttpClient()
+      pic = await client.getContent(url)
+
+    defer: client.close()
+    resp pic, mimetype(url)
+
+  get "/video/@sig/@url":
+    cond "http" in @"url"
+    cond "video.twimg" in @"url"
+    let url = decodeUrl(@"url")
+
+    if getHmac(url) != @"sig":
+      resp showError("Failed to verify signature")
+
+    let
+      client = newAsyncHttpClient()
+      pic = await client.getContent(url)
+
+    defer: client.close()
+    resp pic, mimetype(url)
+
+runForever()

+ 100 - 0
src/parser.nim

@@ -0,0 +1,100 @@
+import xmltree, sequtils, strtabs, strutils, strformat, json, times
+import nimquery, regex
+
+import ./types, ./formatters
+
+proc getAttr(node: XmlNode; attr: string; default=""): string =
+  if node.isNIl or node.attrs.isNil: return default
+  return node.attrs.getOrDefault(attr)
+
+proc selectAttr(node: XmlNode; selector: string; attr: string; default=""): string =
+  let res = node.querySelector(selector)
+  return res.getAttr(attr, default)
+
+proc selectText(node: XmlNode; selector: string): string =
+  let res = node.querySelector(selector)
+  result = if res == nil: "" else: res.innerText()
+
+proc parseProfile*(node: XmlNode): Profile =
+  let profile = node.querySelector(".profile-card")
+  result.fullname = profile.selectText(".fullname")
+  result.username = profile.selectText(".username").strip(chars={'@', ' '})
+  result.description = profile.selectText(".bio")
+  result.verified = profile.selectText("li.verified").len > 0
+  result.protected = profile.selectText(".Icon.Icon--protected").len > 0
+  result.userpic = profile.selectAttr(".ProfileCard-avatarImage", "src").getUserpic()
+  result.banner = profile.selectAttr("svg > image", "xlink:href").replace("600x200", "1500x500")
+  if result.banner == "":
+      result.banner = profile.selectAttr(".ProfileCard-bg", "style")
+
+  let stats = profile.querySelectorAll(".ProfileCardStats-statLink")
+  for s in stats:
+    let text = s.getAttr("title").split(" ")[0]
+    case s.getAttr("href").split("/")[^1]
+    of "followers": result.followers = text
+    of "following": result.following = text
+    else: result.tweets = text
+
+proc parseTweetProfile*(tweet: XmlNode): Profile =
+  result = Profile(
+    fullname: tweet.getAttr("data-name"),
+    username: tweet.getAttr("data-screen-name"),
+    userpic: tweet.selectAttr(".avatar", "src").getUserpic(),
+    verified: tweet.selectText(".Icon.Icon--verified").len > 0
+  )
+
+proc parseTweet*(tweet: XmlNode): Tweet =
+  result.id = tweet.getAttr("data-item-id")
+  result.link = tweet.getAttr("data-permalink-path")
+  result.text = tweet.selectText(".tweet-text").stripTwitterUrls()
+  result.retweetBy = tweet.selectText(".js-retweet-text > a > b")
+  result.pinned = "pinned" in tweet.getAttr("class")
+  result.profile = parseTweetProfile(tweet)
+
+  let time = tweet.querySelector(".js-short-timestamp")
+  result.time = fromUnix(parseInt(time.getAttr("data-time", "0")))
+  result.shortTime = time.innerText()
+
+  result.replies = "0"
+  result.likes = "0"
+  result.retweets = "0"
+
+  for action in tweet.querySelectorAll(".ProfileTweet-actionCountForAria"):
+    let
+      text = action.innerText.split()
+      num = text[0]
+      act = text[1]
+
+    case act
+    of "replies": result.replies = num
+    of "likes": result.likes = num
+    of "retweets": result.retweets = num
+    else: discard
+
+  for photo in tweet.querySelectorAll(".AdaptiveMedia-photoContainer"):
+    result.photos.add photo.attrs["data-image-url"]
+
+  let gif = tweet.selectAttr(".PlayableMedia-player", "style")
+  if gif != "":
+    result.gif = gif.replace(re".+thumb/([^\.']+)\.jpg.+", "$1")
+
+proc parseTweets*(node: XmlNode): Tweets =
+  if node.isNil: return
+  node.querySelectorAll(".tweet").map(parseTweet)
+
+template selectTweets*(node: XmlNode; class: string): untyped =
+  parseTweets(node.querySelector(class))
+
+proc parseConversation*(node: XmlNode): Conversation =
+  result.tweet = parseTweet(node.querySelector(".permalink-tweet-container > .tweet"))
+  result.before = node.selectTweets(".in-reply-to")
+
+  let replies = node.querySelector(".replies-to")
+  if replies.isNil: return
+
+  result.after = replies.selectTweets(".ThreadedConversation--selfThread")
+
+  for reply in replies.querySelectorAll("li > .stream-items"):
+    let thread = parseTweets(reply)
+    if not thread.anyIt(it in result.after):
+      result.replies.add thread

+ 40 - 0
src/types.nim

@@ -0,0 +1,40 @@
+import times, sequtils
+
+type
+  Profile* = object
+    username*: string
+    fullname*: string
+    description*: string
+    userpic*: string
+    banner*: string
+    following*: string
+    followers*: string
+    tweets*: string
+    verified*: bool
+    protected*: bool
+
+  Tweet* = object
+    id*: string
+    profile*: Profile
+    link*: string
+    text*: string
+    time*: Time
+    shortTime*: string
+    replies*: string
+    retweets*: string
+    likes*: string
+    retweetBy*: string
+    pinned*: bool
+    photos*: seq[string]
+    gif*: string
+
+  Tweets* = seq[Tweet]
+
+  Conversation* = object
+    tweet*: Tweet
+    before*: Tweets
+    after*: Tweets
+    replies*: seq[Tweets]
+
+proc contains*(thread: Tweets; tweet: Tweet): bool =
+  thread.anyIt(it.id == tweet.id)

+ 23 - 0
src/utils.nim

@@ -0,0 +1,23 @@
+import strutils, strformat, uri
+import nimcrypto
+
+const key = "supersecretkey"
+
+proc mimetype*(filename: string): string =
+  if ".png" in filename:
+    return "image/" & "png"
+  elif ".jpg" in filename or ".jpeg" in filename:
+    return "image/" & "jpg"
+  elif ".mp4" in filename:
+    return "video/" & "mp4"
+  else:
+    return "text/plain"
+
+proc getHmac*(data: string): string =
+  ($hmac(sha256, key, data))[0 .. 12]
+
+proc getSigUrl*(link: string; path: string): string =
+  let
+    sig = getHmac(link)
+    url = encodeUrl(link)
+  &"/{path}/{sig}/{url}"

+ 38 - 0
src/views/conversation.nim

@@ -0,0 +1,38 @@
+#? stdtmpl(subsChar = '$', metaChar = '#')
+#import xmltree, strutils, uri
+#import ../types, ../formatters, ./tweet
+#
+#proc renderConversation*(conversation: Conversation): string =
+<div class="conversation" id="tweets">
+  <div class="main-thread">
+    #if conversation.before.len > 0:
+    <div class="before-tweet">
+      #for tweet in conversation.before:
+      ${renderTweet(tweet)}
+      #end for
+    </div>
+    #end if
+    <div class="main-tweet">
+      ${renderTweet(conversation.tweet)}
+    </div>
+    #if conversation.after.len > 0:
+    <div class="after-tweet">
+      #for tweet in conversation.after:
+      ${renderTweet(tweet)}
+      #end for
+    </div>
+    #end if
+  </div>
+  #if conversation.replies.len > 0:
+  <div class="replies">
+    #for thread in conversation.replies:
+    <div class="thread">
+      #for tweet in thread:
+      ${renderTweet(tweet)}
+      #end for
+    </div>
+    #end for
+  </div>
+  #end if
+</div>
+#end proc

+ 50 - 0
src/views/general.nim

@@ -0,0 +1,50 @@
+#? stdtmpl(subsChar = '$', metaChar = '#')
+#import user
+#import xmltree
+#
+#proc renderMain*(body: string): string =
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>Nitter</title>
+    <link rel="stylesheet" type="text/css" href="/style.css">
+  </head>
+
+  <body>
+    <nav id="nav" class="nav-bar container">
+      <div class="inner-nav">
+        <div class="item">
+          <a href="/" class="site-name">twatter</a>
+        </div>
+      </div>
+    </nav>
+
+    <div id="content" class="container">
+      ${body}
+    </div>
+  </body>
+</html>
+#end proc
+#
+#proc renderSearchPanel*(): string =
+<div class="panel">
+  <div class="search-panel">
+    <form action="search" method="post">
+      <input type="text" name="query" placeholder="Enter username...">
+      <button type="submit" name="button">🔎</button>
+    </form>
+  </div>
+</div>
+#end proc
+#
+#proc renderError*(error: string): string =
+<div class="panel">
+  <div class="error-panel">
+    <span>${error}</span>
+  </div>
+</div>
+#end proc
+#
+#proc showError*(error: string): string =
+${renderMain(renderError(error))}
+#end proc

+ 99 - 0
src/views/tweet.nim

@@ -0,0 +1,99 @@
+#? stdtmpl(subsChar = '$', metaChar = '#')
+#import xmltree, strutils, times, sequtils, uri
+#import ../types, ../formatters, ../utils
+#
+#proc renderHeading(tweet: Tweet): string =
+#if tweet.retweetBy != "":
+  <div class="retweet">
+    <span>🔄 ${tweet.retweetBy} retweeted</span>
+  </div>
+#end if
+#if tweet.pinned:
+  <div class="pinned">
+    <span>📌 Pinned Tweet</span>
+  </div>
+#end if
+<div class="media-heading">
+  <div class="heading-name-row">
+    <img class="avatar" src=${tweet.profile.getUserpic("_bigger").getSigUrl("pic")}>
+    <div class="name-and-account-name">
+      ${linkUser(tweet.profile, "h4", class="username", username=false)}
+      ${linkUser(tweet.profile, "", class="account-name")}
+    </div>
+    <span class="heading-right">
+      <a href="${tweet.link}" class="timeago faint-link">
+        <time title="${tweet.time.format("d/M/yyyy', ' HH:mm:ss")}">${tweet.shortTime}</time>
+      </a>
+    </span>
+  </div>
+</div>
+#end proc
+#
+#proc renderMediaGroup(tweet: Tweet): string =
+#let groups = if tweet.photos.len > 2: tweet.photos.distribute(2) else: @[tweet.photos]
+#let groupStyle = if groups.len == 1 and groups[0].len < 2: "" else: "background-color: #0f0f0f;"
+#var first = true
+<div class="attachments media-body" style="${groupStyle}">
+#for photos in groups:
+  #let style = if first: "" else: "margin-top: .25em;"
+  <div class="gallery-row cover-fit" style="${style}">
+    #for photo in photos:
+    <div class="attachment image">
+      ##TODO: why doesn't this work?
+      <a href=${getSigUrl(photo & ":large", "pic")} target="_blank" class="image-attachment">
+        #let style = if photos.len > 1 or groups.len > 1: "display: flex;" else: ""
+        #let istyle = if photos.len > 1 or groups.len > 1: "" else: "border-radius: 7px;"
+        <div class="still-image" style="${style}">
+          <img src=${getSigUrl(photo, "pic")} referrerpolicy="" style="${istyle}">
+        </div>
+      </a>
+    </div>
+    #end for
+  </div>
+  #first = false
+#end for
+</div>
+#end proc
+#
+#proc renderGif(tweet: Tweet): string =
+#let thumbUrl = getGifThumb(tweet).getSigUrl("pic")
+#let videoUrl = getGifSrc(tweet).getSigUrl("video")
+<div class="attachments media-body">
+  <div class="gallery-row" style="max-height: unset;">
+    <div class="attachment image">
+      <video poster=${thumbUrl} style="width: 100%; height: 100%;" autoplay muted loop>
+        <source src=${videoUrl} type="video/mp4">
+      </video>
+    </div>
+  </div>
+</div>
+#end proc
+#
+#proc renderStats(tweet: Tweet): string =
+<div class="tweet-stats">
+  <span class="tweet-stat">💬 ${$tweet.replies}</span>
+  <span class="tweet-stat">🔄 ${$tweet.retweets}</span>
+  <span class="tweet-stat">👍 ${$tweet.likes}</span>
+</div>
+#end proc
+#
+#proc renderTweet*(tweet: Tweet; class=""): string =
+<div class="${class}">
+  <div class="status-el">
+    <div class="status-body">
+      ${renderHeading(tweet)}
+      <div class="status-content-wrapper">
+        <div class="status-content media-body">
+          ${linkifyText(tweet.text)}
+        </div>
+      </div>
+      #if tweet.photos.len > 0:
+        ${renderMediaGroup(tweet)}
+      #elif tweet.gif.len > 0:
+        ${renderGif(tweet)}
+      #end if
+      ${renderStats(tweet)}
+    </div>
+  </div>
+</div>
+#end proc

+ 100 - 0
src/views/user.nim

@@ -0,0 +1,100 @@
+#? stdtmpl(subsChar = '$', metaChar = '#')
+#import xmltree, strutils, uri, htmlgen
+#import ../types, ../formatters, ../utils
+#import ./tweet
+#
+#proc renderProfileCard*(profile: Profile): string =
+#let pic = profile.getUserpic().getSigUrl("pic")
+#let smallPic = profile.getUserpic("_200x200").getSigUrl("pic")
+<div class="profile-card">
+  <a class="profile-card-avatar" href="${pic}">
+    <img src="${smallPic}">
+  </a>
+  <div class="profile-card-tabs">
+    <div class="profile-card-tabs-name">
+      ${linkUser(profile, "h1", class="profile-card-name", username=false)}
+      ${linkUser(profile, "h2", class="profile-card-username")}
+    </div>
+  </div>
+  <div class="profile-card-extra">
+    <div class="profile-bio">
+      #if profile.description.len > 0:
+      <div class="profile-description">
+        <p>${linkifyText(xmltree.escape(profile.description))}</p>
+      </div>
+      #end if
+    </div>
+
+    <div class="profile-card-extra-links">
+      <ul class="profile-statlist">
+        <li class="tweets">
+          <span class="profile-stat-header">Tweets</span>
+          <span>${$profile.tweets}</span>
+        </li>
+        <li class="followers">
+          <span class="profile-stat-header">Followers</span>
+          <span>${$profile.followers}</span>
+        </li>
+        <li class="following">
+          <span class="profile-stat-header">Following</span>
+          <span>${$profile.following}</span>
+        </li>
+      </ul>
+    </div>
+  </div>
+</div>
+#end proc
+#
+#proc renderBanner(profile: Profile): string =
+#if "#" in profile.banner:
+<div style="${profile.banner}" class="profile-banner-color"></div>
+#else:
+#let url = getSigUrl(profile.banner, "pic")
+<a href="${url}">
+  <img src="${url}">
+</a>
+#end if
+#end proc
+#
+#proc renderTimeline*(tweets: Tweets; profile: Profile; beginning: bool): string =
+<div id="tweets">
+  #if profile.protected:
+  <div class="timeline-protected">
+    <h2 class="timeline-protected-header">This account's Tweets are protected.</h2>
+    <p class="timeline-protected-explanation">Only confirmed followers have access to @${profile.username}'s Tweets.
+  </div>
+  #end if
+  #if not beginning:
+  <div class="show-more status-el">
+    <a href="/${profile.username}">Load newest tweets</a>
+  </div>
+  #end if
+  #var retweets: Tweets
+  #for tweet in tweets:
+    #if tweet in retweets: continue
+    #end if
+    #if tweet.retweetBy.len > 0: retweets.add tweet
+    #end if
+    ${renderTweet(tweet, "timeline-tweet")}
+  #end for
+  #if tweets.len > 0:
+  <div class="show-more">
+    <a href="/${profile.username}?after=${$tweets[^1].id}">Load older tweets</a>
+  </div>
+  #end if
+</div>
+#end proc
+#
+#proc renderProfile*(profile: Profile; tweets: Tweets; beginning: bool): string =
+<div class="profile-tabs">
+  <div class="profile-banner">
+    ${renderBanner(profile)}
+  </div>
+  <div class="profile-tab">
+    ${renderProfileCard(profile)}
+  </div>
+  <div class="timeline-tab">
+    ${renderTimeline(tweets, profile, beginning)}
+  </div>
+</div>
+#end proc