Initial commit

This commit is contained in:
Zed 2019-06-20 16:16:20 +02:00
commit cea5cc0523
14 changed files with 1370 additions and 0 deletions

2
.gitignore vendored Normal file
View file

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

14
nitter.nimble Normal file
View file

@ -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
public/style.css Normal file
View file

@ -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
src/api.nim Normal file
View file

@ -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
src/cache.nim Normal file
View file

@ -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
src/formatters.nim Normal file
View file

@ -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
src/nitter.nim Normal file
View file

@ -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
src/parser.nim Normal file
View file

@ -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
src/types.nim Normal file
View file

@ -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
src/utils.nim Normal file
View file

@ -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}"

View file

@ -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
src/views/general.nim Normal file
View file

@ -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
src/views/tweet.nim Normal file
View file

@ -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
src/views/user.nim Normal file
View file

@ -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