diff --git a/nitter.conf b/nitter.conf
index 2e3c023..41b961c 100644
--- a/nitter.conf
+++ b/nitter.conf
@@ -1,6 +1,7 @@
[Server]
address = "0.0.0.0"
port = 8080
+https = true # disable to enable cookies when not using https
title = "nitter"
staticDir = "./public"
diff --git a/nitter.nimble b/nitter.nimble
index 039b7d0..30e43cf 100644
--- a/nitter.nimble
+++ b/nitter.nimble
@@ -11,8 +11,8 @@ bin = @["nitter"]
# Dependencies
requires "nim >= 0.19.9"
-requires "norm >= 1.0.11"
-requires "jester >= 0.4.1"
+requires "norm >= 1.0.13"
+requires "jester >= 0.4.3"
requires "regex >= 0.11.2"
requires "q >= 0.0.7"
requires "nimcrypto >= 0.3.9"
diff --git a/public/css/fontello.css b/public/css/fontello.css
new file mode 100644
index 0000000..2d9f3b8
--- /dev/null
+++ b/public/css/fontello.css
@@ -0,0 +1,53 @@
+@font-face {
+ font-family: 'fontello';
+ src: url('/fonts/fontello.eot?39973630');
+ src: url('/fonts/fontello.eot?39973630#iefix') format('embedded-opentype'),
+ url('/fonts/fontello.woff2?39973630') format('woff2'),
+ url('/fonts/fontello.woff?39973630') format('woff'),
+ url('/fonts/fontello.ttf?39973630') format('truetype'),
+ url('/fonts/fontello.svg?39973630#fontello') format('svg');
+ font-weight: normal;
+ font-style: normal;
+}
+
+ [class^="icon-"]:before, [class*=" icon-"]:before {
+ font-family: "fontello";
+ font-style: normal;
+ font-weight: normal;
+ speak: none;
+
+ display: inline-block;
+ text-decoration: inherit;
+ width: 1em;
+ text-align: center;
+
+ /* For safety - reset parent styles, that can break glyph codes*/
+ font-variant: normal;
+ text-transform: none;
+
+ /* fix buttons height, for twitter bootstrap */
+ line-height: 1em;
+
+ /* Font smoothing. That was taken from TWBS */
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+.icon-help-circled:before { content: '\e800'; } /* '' */
+.icon-attention:before { content: '\e801'; } /* '' */
+.icon-comment:before { content: '\e802'; } /* '' */
+.icon-ok:before { content: '\e803'; } /* '' */
+.icon-link:before { content: '\e805'; } /* '' */
+.icon-calendar:before { content: '\e806'; } /* '' */
+.icon-location:before { content: '\e807'; } /* '' */
+.icon-down-open-1:before { content: '\e808'; } /* '' */
+.icon-picture-1:before { content: '\e809'; } /* '' */
+.icon-lock-circled:before { content: '\e80a'; } /* '' */
+.icon-down-open:before { content: '\e80b'; } /* '' */
+.icon-info-circled:before { content: '\e80c'; } /* '' */
+.icon-retweet-1:before { content: '\e80d'; } /* '' */
+.icon-search:before { content: '\e80e'; } /* '' */
+.icon-pin:before { content: '\e80f'; } /* '' */
+.icon-ok-circled:before { content: '\e810'; } /* '' */
+.icon-cog-2:before { content: '\e812'; } /* '' */
+.icon-thumbs-up-alt:before { content: '\f164'; } /* '' */
diff --git a/public/style.css b/public/css/style.css
similarity index 86%
rename from public/style.css
rename to public/css/style.css
index 85fa05e..a523a4a 100644
--- a/public/style.css
+++ b/public/css/style.css
@@ -7,6 +7,10 @@ body {
line-height: 1.3;
}
+* {
+ outline: unset;
+}
+
#posts {
background-color: #161616;
}
@@ -107,29 +111,19 @@ a:hover {
text-overflow: ellipsis;
}
-.icon {
+.verified-icon {
color: #fff;
+ background-color: #1da1f2;
border-radius: 50%;
+ flex-shrink: 0;
+ margin: 2px 0 3px 3px;
+ padding-top: 2px;
+ height: 12px;
+ width: 14px;
+ font-size: 8px;
display: inline-block;
text-align: center;
vertical-align: middle;
- flex-shrink: 0;
- margin: 2px 0 3px 3px;
-}
-
-.verified-icon {
- background-color: #1da1f2;
- height: 14px;
- width: 14px;
- font-size: 10px;
-}
-
-.protected-icon {
- background-color: #353535;
- height: 18px;
- width: 18px;
- font-size: 12px;
- font-weight: bold;
}
.tweet-date {
@@ -210,19 +204,28 @@ nav {
justify-content: flex-end;
}
+.site-name {
+ font-weight: 600;
+}
+
+.site-name:hover {
+ color: #ffaca0;
+ text-decoration: unset;
+}
+
.site-logo {
+ display: block;
width: 35px;
height: 35px;
}
-.site-about {
- font-size: 17px;
- padding-right: 2px;
- margin-top: -0.75px;
+.item.right a {
+ padding-left: 4px;
}
-.site-settings {
- font-size: 18px;
+.item.right a:hover {
+ color: #ffaca0;
+ text-decoration: unset;
}
.attachments {
@@ -277,7 +280,7 @@ nav {
overflow: hidden;
}
-video {
+video, .video-container img {
height: 100%;
width: 100%;
}
@@ -386,10 +389,15 @@ video {
padding: 0 2em;
line-height: 2em;
}
+
.show-more a:hover {
background-color: #282828;
}
+.show-thread {
+ display: block;
+}
+
.multi-header {
background-color: #161616;
text-align: center;
@@ -437,7 +445,6 @@ video {
text-align: left;
vertical-align: top;
max-width: 32%;
- position: sticky;
top: 50px;
}
@@ -898,12 +905,8 @@ video {
}
.quote-sensitive-icon {
- font-size: 25px;
- width: 37px;
- height: 32px;
- background-color: #4e4e4e;
- padding-bottom: 5px;
- margin: 0;
+ font-size: 40px;
+ color: #909090;
}
.card {
@@ -1073,3 +1076,132 @@ video {
.poll-info {
color: #868687;
}
+
+.preferences-container {
+ max-width: 600px;
+ margin: 0 auto;
+ width: 100%;
+ margin-top: 10px;
+}
+
+.preferences {
+ background-color: #1f1f1f;
+ width: 100%;
+ padding: 5px 15px 15px 15px;
+}
+
+.preferences input[type="text"] {
+ max-width: 120px;
+ background-color: #121212;
+ padding: 1px 4px;
+ color: #f8f8f2;
+ margin: 0;
+ border: 1px solid #ff6c6091;
+ border-radius: 0px;
+ position: absolute;
+ right: 0;
+ font-size: 14px;
+}
+
+.preferences input[type="text"]:hover {
+ border-color: #ff6c60;
+}
+
+fieldset {
+ margin: .35em 0 .75em;
+ border: 0;
+}
+
+legend {
+ width: 100%;
+ padding: .6em 0 .3em 0;
+ margin: 0;
+ border: 0;
+ font-size: 16px;
+ border-bottom: 1px solid #3e3e35;
+ margin-bottom: 8px;
+}
+
+.pref-input {
+ position: relative;
+ margin-bottom: 6px;
+}
+
+.pref-submit, .pref-reset button {
+ background-color: #121212;
+ color: #f8f8f2;
+ border: 1px solid #ff6c6091;
+ padding: 3px 6px;
+ margin-top: 6px;
+ font-size: 14px;
+ cursor: pointer;
+ float: right;
+}
+
+.pref-submit:hover, .pref-reset button:hover {
+ border-color: #ff6c60;
+}
+
+.pref-submit:active, .pref-reset button:active {
+ border-color: #ff9f97;
+}
+
+.pref-reset {
+ float: left;
+}
+
+.icon-container {
+ display: inline;
+}
+
+.checkbox-container {
+ display: block;
+ position: relative;
+ margin-bottom: 5px;
+ cursor: pointer;
+ user-select: none;
+}
+
+.checkbox-container input {
+ position: absolute;
+ opacity: 0;
+ cursor: pointer;
+ height: 0;
+ width: 0;
+}
+
+.checkbox {
+ position: absolute;
+ top: 1px;
+ right: 0;
+ height: 17px;
+ width: 17px;
+ background-color: #121212;
+ border: 1px solid #ff6c6091;
+}
+
+.checkbox-container:hover input ~ .checkbox {
+ border-color: #ff6c60;
+}
+
+.checkbox-container:active input ~ .checkbox {
+ border-color: #ff9f97;
+}
+
+.checkbox:after {
+ content: "";
+ position: absolute;
+ display: none;
+}
+
+.checkbox-container input:checked ~ .checkbox:after {
+ display: block;
+}
+
+.checkbox-container .checkbox:after {
+ left: 2px;
+ bottom: 0px;
+ font-size: 13px;
+ font-family: "fontello";
+ content: '\e803';
+}
diff --git a/public/fonts/LICENSE.txt b/public/fonts/LICENSE.txt
new file mode 100644
index 0000000..1d98124
--- /dev/null
+++ b/public/fonts/LICENSE.txt
@@ -0,0 +1,39 @@
+Font license info
+
+
+## Entypo
+
+ Copyright (C) 2012 by Daniel Bruce
+
+ Author: Daniel Bruce
+ License: SIL (http://scripts.sil.org/OFL)
+ Homepage: http://www.entypo.com
+
+
+## MFG Labs
+
+ Copyright (C) 2012 by Daniel Bruce
+
+ Author: MFG Labs
+ License: SIL (http://scripts.sil.org/OFL)
+ Homepage: http://www.mfglabs.com/
+
+
+## Font Awesome
+
+ Copyright (C) 2016 by Dave Gandy
+
+ Author: Dave Gandy
+ License: SIL ()
+ Homepage: http://fortawesome.github.com/Font-Awesome/
+
+
+## Elusive
+
+ Copyright (C) 2013 by Aristeides Stathopoulos
+
+ Author: Aristeides Stathopoulos
+ License: SIL (http://scripts.sil.org/OFL)
+ Homepage: http://aristeides.com/
+
+
diff --git a/public/fonts/fontello.eot b/public/fonts/fontello.eot
new file mode 100644
index 0000000..43d722f
Binary files /dev/null and b/public/fonts/fontello.eot differ
diff --git a/public/fonts/fontello.svg b/public/fonts/fontello.svg
new file mode 100644
index 0000000..90cf3ca
--- /dev/null
+++ b/public/fonts/fontello.svg
@@ -0,0 +1,46 @@
+
+
+
\ No newline at end of file
diff --git a/public/fonts/fontello.ttf b/public/fonts/fontello.ttf
new file mode 100644
index 0000000..6d82a62
Binary files /dev/null and b/public/fonts/fontello.ttf differ
diff --git a/public/fonts/fontello.woff b/public/fonts/fontello.woff
new file mode 100644
index 0000000..100da96
Binary files /dev/null and b/public/fonts/fontello.woff differ
diff --git a/public/fonts/fontello.woff2 b/public/fonts/fontello.woff2
new file mode 100644
index 0000000..56cd672
Binary files /dev/null and b/public/fonts/fontello.woff2 differ
diff --git a/src/api.nim b/src/api.nim
index a4c0c25..3ab0397 100644
--- a/src/api.nim
+++ b/src/api.nim
@@ -52,10 +52,10 @@ macro genMediaGet(media: untyped; token=false) =
var futs: seq[Future[void]]
when `token`:
var token = await getGuestToken(agent)
- futs.add `single`(convo.tweet, token, agent)
- futs.add `multi`(convo.before, token, agent)
- futs.add `multi`(convo.after, token, agent)
- futs.add convo.replies.mapIt(`multi`(it, token, agent))
+ futs.add `single`(convo.tweet, agent, token)
+ futs.add `multi`(convo.before, agent, token=token)
+ futs.add `multi`(convo.after, agent, token=token)
+ futs.add convo.replies.mapIt(`multi`(it, agent, token=token))
else:
futs.add `single`(convo.tweet, agent)
futs.add `multi`(convo.before, agent)
@@ -117,7 +117,7 @@ proc getGuestToken(agent: string; force=false): Future[string] {.async.} =
result = json["guest_token"].to(string)
guestToken = result
-proc getVideoFetch*(tweet: Tweet; token, agent: string) {.async.} =
+proc getVideoFetch*(tweet: Tweet; agent, token: string) {.async.} =
if tweet.video.isNone(): return
let headers = newHttpHeaders({
@@ -135,7 +135,7 @@ proc getVideoFetch*(tweet: Tweet; token, agent: string) {.async.} =
if getTime() - tokenUpdated > initDuration(seconds=1):
tokenUpdated = getTime()
discard await getGuestToken(agent, force=true)
- await getVideoFetch(tweet, guestToken, agent)
+ await getVideoFetch(tweet, agent, guestToken)
return
if tweet.card.isNone:
@@ -151,12 +151,12 @@ proc getVideoVar*(tweet: Tweet): var Option[Video] =
else:
return tweet.video
-proc getVideo*(tweet: Tweet; token, agent: string; force=false) {.async.} =
+proc getVideo*(tweet: Tweet; agent, token: string; force=false) {.async.} =
withDb:
try:
getVideoVar(tweet) = some(Video.getOne("videoId = ?", tweet.id))
except KeyError:
- await getVideoFetch(tweet, token, agent)
+ await getVideoFetch(tweet, agent, token)
var video = getVideoVar(tweet)
if video.isSome():
get(video).insert()
diff --git a/src/cache.nim b/src/cache.nim
index f010973..ebba87a 100644
--- a/src/cache.nim
+++ b/src/cache.nim
@@ -1,7 +1,7 @@
import asyncdispatch, times
import types, api
-withDb:
+withCustomDb("cache.db", "", "", ""):
try:
createTables()
except DbError:
@@ -13,7 +13,7 @@ proc isOutdated*(profile: Profile): bool =
getTime() - profile.updated > profileCacheTime
proc cache*(profile: var Profile) =
- withDb:
+ withCustomDb("cache.db", "", "", ""):
try:
let p = Profile.getOne("lower(username) = ?", toLower(profile.username))
profile.id = p.id
@@ -23,7 +23,7 @@ proc cache*(profile: var Profile) =
profile.insert()
proc hasCachedProfile*(username: string): Option[Profile] =
- withDb:
+ withCustomDb("cache.db", "", "", ""):
try:
let p = Profile.getOne("lower(username) = ?", toLower(username))
doAssert not p.isOutdated
@@ -32,7 +32,7 @@ proc hasCachedProfile*(username: string): Option[Profile] =
result = none(Profile)
proc getCachedProfile*(username, agent: string; force=false): Future[Profile] {.async.} =
- withDb:
+ withCustomDb("cache.db", "", "", ""):
try:
result.getOne("lower(username) = ?", toLower(username))
doAssert not result.isOutdated
diff --git a/src/config.nim b/src/config.nim
index 34cd1f3..2b2317f 100644
--- a/src/config.nim
+++ b/src/config.nim
@@ -1,5 +1,5 @@
import parsecfg except Config
-import os, net, types, strutils
+import net, types, strutils
proc get[T](config: parseCfg.Config; s, v: string; default: T): T =
let val = config.getSectionValue(s, v)
@@ -15,6 +15,7 @@ proc getConfig*(path: string): Config =
Config(
address: cfg.get("Server", "address", "0.0.0.0"),
port: cfg.get("Server", "port", 8080),
+ useHttps: cfg.get("Server", "https", true),
title: cfg.get("Server", "title", "Nitter"),
staticDir: cfg.get("Server", "staticDir", "./public"),
diff --git a/src/formatters.nim b/src/formatters.nim
index 9e27fe5..6c68425 100644
--- a/src/formatters.nim
+++ b/src/formatters.nim
@@ -11,6 +11,8 @@ const
usernameRegex = re"(^|[^A-z0-9_?])@([A-z0-9_]+)"
picRegex = re"pic.twitter.com/[^ ]+"
ellipsisRegex = re" ?…"
+ ytRegex = re"(www.)?youtu(be.com|.be)"
+ twRegex = re"(www.)?twitter.com"
nbsp = $Rune(0x000A0)
proc stripText*(text: string): string =
@@ -46,7 +48,7 @@ proc reUsernameToLink*(m: RegexMatch; s: string): string =
pretext & toLink("/" & username, "@" & username)
-proc linkifyText*(text: string): string =
+proc linkifyText*(text: string; prefs: Prefs): string =
result = xmltree.escape(stripText(text))
result = result.replace(ellipsisRegex, "")
result = result.replace(emailRegex, reEmailToLink)
@@ -55,6 +57,17 @@ proc linkifyText*(text: string): string =
result = result.replace(re"([^\s\(\n%])\s+([;.,!\)'%]|')", "$1")
result = result.replace(re"^\. 0:
+ result = result.replace(ytRegex, prefs.replaceYouTube)
+ if prefs.replaceTwitter.len > 0:
+ result = result.replace(twRegex, prefs.replaceTwitter)
+
+proc replaceUrl*(url: string; prefs: Prefs): string =
+ result = url
+ if prefs.replaceYouTube.len > 0:
+ result = result.replace(ytRegex, prefs.replaceYouTube)
+ if prefs.replaceTwitter.len > 0:
+ result = result.replace(twRegex, prefs.replaceTwitter)
proc stripTwitterUrls*(text: string): string =
result = text
diff --git a/src/nitter.nim b/src/nitter.nim
index a2dea9a..0d737d0 100644
--- a/src/nitter.nim
+++ b/src/nitter.nim
@@ -1,15 +1,17 @@
-import asyncdispatch, asyncfile, httpclient, sequtils, strutils, strformat, uri, os
+import asyncdispatch, asyncfile, httpclient, uri, os
+import sequtils, strformat, strutils
from net import Port
import jester, regex
-import api, utils, types, cache, formatters, search, config, agents
-import views/[general, profile, status]
+import api, utils, types, cache, formatters, search, config, prefs, agents
+import views/[general, profile, status, preferences]
const configPath {.strdefine.} = "./nitter.conf"
let cfg = getConfig(configPath)
-proc showSingleTimeline(name, after, agent: string; query: Option[Query]): Future[string] {.async.} =
+proc showSingleTimeline(name, after, agent: string; query: Option[Query];
+ prefs: Prefs): Future[string] {.async.} =
let railFut = getPhotoRail(name, agent)
var timeline: Timeline
@@ -34,33 +36,40 @@ proc showSingleTimeline(name, after, agent: string; query: Option[Query]): Futur
if profile.username.len == 0:
return ""
- let profileHtml = renderProfile(profile, timeline, await railFut)
- return renderMain(profileHtml, title=cfg.title, titleText=pageTitle(profile), desc=pageDesc(profile))
+ let profileHtml = renderProfile(profile, timeline, await railFut, prefs)
+ return renderMain(profileHtml, cfg.title, pageTitle(profile), pageDesc(profile))
-proc showMultiTimeline(names: seq[string]; after, agent: string; query: Option[Query]): Future[string] {.async.} =
+proc showMultiTimeline(names: seq[string]; after, agent: string; query: Option[Query];
+ prefs: Prefs): Future[string] {.async.} =
var q = query
if q.isSome:
get(q).fromUser = names
else:
q = some(Query(kind: multi, fromUser: names, excludes: @["replies"]))
- var timeline = renderMulti(await getTimelineSearch(get(q), after, agent), names.join(","))
- return renderMain(timeline, title=cfg.title, titleText="Multi")
+ var timeline = renderMulti(await getTimelineSearch(get(q), after, agent),
+ names.join(","), prefs)
-proc showTimeline(name, after: string; query: Option[Query]): Future[string] {.async.} =
+ return renderMain(timeline, cfg.title, "Multi")
+
+proc showTimeline(name, after: string; query: Option[Query];
+ prefs: Prefs): Future[string] {.async.} =
let agent = getAgent()
let names = name.strip(chars={'/'}).split(",").filterIt(it.len > 0)
if names.len == 1:
- return await showSingleTimeline(names[0], after, agent, query)
+ return await showSingleTimeline(names[0], after, agent, query, prefs)
else:
- return await showMultiTimeline(names, after, agent, query)
+ return await showMultiTimeline(names, after, agent, query, prefs)
template respTimeline(timeline: typed) =
if timeline.len == 0:
resp Http404, showError("User \"" & @"name" & "\" not found", cfg.title)
resp timeline
+template cookiePrefs(): untyped {.dirty.} =
+ getPrefs(request.cookies.getOrDefault("preferences"))
+
setProfileCacheTime(cfg.profileCacheTime)
settings:
@@ -70,32 +79,56 @@ settings:
routes:
get "/":
- resp renderMain(renderSearch(), title=cfg.title)
+ resp renderMain(renderSearch(), cfg.title)
post "/search":
if @"query".len == 0:
resp Http404, showError("Please enter a username.", cfg.title)
redirect("/" & @"query")
+ post "/saveprefs":
+ var prefs = cookiePrefs()
+ genUpdatePrefs()
+ setCookie("preferences", $prefs.id, daysForward(360), httpOnly=true, secure=cfg.useHttps)
+ redirect(decodeUrl(@"referer"))
+
+ post "/resetprefs":
+ var prefs = cookiePrefs()
+ resetPrefs(prefs)
+ setCookie("preferences", $prefs.id, daysForward(360), httpOnly=true, secure=cfg.useHttps)
+ redirect("/settings")
+
+ get "/settings":
+ let refUri = request.headers.getOrDefault("Referer").parseUri()
+ var path =
+ if refUri.path.len > 0 and "/settings" notin refUri.path: refUri.path
+ else: "/"
+ if refUri.query.len > 0: path &= &"?{refUri.query}"
+ resp renderMain(renderPreferences(cookiePrefs(), path), cfg.title, "Preferences")
+
get "/@name/?":
cond '.' notin @"name"
- respTimeline(await showTimeline(@"name", @"after", none(Query)))
+ respTimeline(await showTimeline(@"name", @"after", none(Query), cookiePrefs()))
get "/@name/search":
cond '.' notin @"name"
+ let prefs = cookiePrefs()
let query = initQuery(@"filter", @"include", @"not", @"sep", @"name")
- respTimeline(await showTimeline(@"name", @"after", some(query)))
+ respTimeline(await showTimeline(@"name", @"after", some(query), cookiePrefs()))
get "/@name/replies":
cond '.' notin @"name"
- respTimeline(await showTimeline(@"name", @"after", some(getReplyQuery(@"name"))))
+ let prefs = cookiePrefs()
+ respTimeline(await showTimeline(@"name", @"after", some(getReplyQuery(@"name")), cookiePrefs()))
get "/@name/media":
cond '.' notin @"name"
- respTimeline(await showTimeline(@"name", @"after", some(getMediaQuery(@"name"))))
+ let prefs = cookiePrefs()
+ respTimeline(await showTimeline(@"name", @"after", some(getMediaQuery(@"name")), cookiePrefs()))
get "/@name/status/@id":
cond '.' notin @"name"
+ let prefs = cookiePrefs()
let conversation = await getTweet(@"name", @"id", getAgent())
if conversation == nil or conversation.tweet.id.len == 0:
@@ -103,26 +136,24 @@ routes:
let title = pageTitle(conversation.tweet.profile)
let desc = conversation.tweet.text
- let html = renderConversation(conversation)
+ let html = renderConversation(conversation, prefs)
if conversation.tweet.video.isSome():
let thumb = get(conversation.tweet.video).thumb
let vidUrl = getVideoEmbed(conversation.tweet.id)
- resp renderMain(html, title=cfg.title, titleText=title, desc=desc,
- images = @[thumb], `type`="video", video=vidUrl)
+ resp renderMain(html, cfg.title, title, desc, images = @[thumb],
+ `type`="video", video=vidUrl)
elif conversation.tweet.gif.isSome():
let thumb = get(conversation.tweet.gif).thumb
let vidUrl = getVideoEmbed(conversation.tweet.id)
- resp renderMain(html, title=cfg.title, titleText=title, desc=desc,
- images = @[thumb], `type`="video", video=vidUrl)
+ resp renderMain(html, cfg.title, title, desc, images = @[thumb],
+ `type`="video", video=vidUrl)
else:
- resp renderMain(html, title=cfg.title, titleText=title,
- desc=desc, images=conversation.tweet.photos)
+ resp renderMain(html, cfg.title, title, desc, images=conversation.tweet.photos)
get "/pic/@sig/@url":
cond "http" in @"url"
cond "twimg" in @"url"
-
let
uri = parseUri(decodeUrl(@"url"))
path = uri.path.split("/")[2 .. ^1].join("/")
@@ -156,11 +187,10 @@ routes:
if getHmac(url) != @"sig":
resp showError("Failed to verify signature", cfg.title)
- let
- client = newAsyncHttpClient()
- video = await client.getContent(url)
+ let client = newAsyncHttpClient()
+ let video = await client.getContent(url)
+ client.close()
- defer: client.close()
resp video, mimetype(url)
runForever()
diff --git a/src/prefs.nim b/src/prefs.nim
new file mode 100644
index 0000000..e2aea5c
--- /dev/null
+++ b/src/prefs.nim
@@ -0,0 +1,47 @@
+import sequtils, macros
+import types
+import prefs_impl
+
+export genUpdatePrefs
+
+static:
+ var pFields: seq[string]
+ for id in getTypeImpl(Prefs)[2]:
+ if $id[0] == "id": continue
+ pFields.add $id[0]
+
+ let pDefs = toSeq(allPrefs()).mapIt(it.name)
+ let missing = pDefs.filterIt(it notin pFields)
+ if missing.len > 0:
+ raiseAssert("{$1} missing from the Prefs type" % missing.join(", "))
+
+withCustomDb("prefs.db", "", "", ""):
+ try:
+ createTables()
+ except DbError:
+ discard
+
+proc cache*(prefs: var Prefs) =
+ withCustomDb("prefs.db", "", "", ""):
+ try:
+ doAssert prefs.id != 0
+ discard Prefs.getOne("id = ?", prefs.id)
+ prefs.update()
+ except AssertionError, KeyError:
+ prefs.insert()
+
+proc getPrefs*(id: string): Prefs =
+ if id.len == 0: return genDefaultPrefs()
+
+ withCustomDb("prefs.db", "", "", ""):
+ try:
+ result.getOne("id = ?", id)
+ except KeyError:
+ result = genDefaultPrefs()
+ cache(result)
+
+proc resetPrefs*(prefs: var Prefs) =
+ var defPrefs = genDefaultPrefs()
+ defPrefs.id = prefs.id
+ cache(defPrefs)
+ prefs = defPrefs
diff --git a/src/prefs_impl.nim b/src/prefs_impl.nim
new file mode 100644
index 0000000..eda6cb2
--- /dev/null
+++ b/src/prefs_impl.nim
@@ -0,0 +1,103 @@
+import macros, tables, strutils, xmltree
+
+const hostname {.strdefine.} = "nitter.net"
+
+type
+ PrefKind* = enum
+ checkbox, select, input
+
+ Pref* = object
+ name*: string
+ label*: string
+ case kind*: PrefKind
+ of checkbox:
+ defaultState*: bool
+ of select:
+ defaultOption*: string
+ options*: seq[string]
+ of input:
+ defaultInput*: string
+ placeholder*: string
+
+# TODO: write DSL to simplify this
+const prefList*: Table[string, seq[Pref]] = {
+ "Privacy": @[
+ Pref(kind: input, name: "replaceTwitter",
+ label: "Replace Twitter links with Nitter (blank to disable)",
+ defaultInput: hostname, placeholder: "Nitter hostname"),
+
+ Pref(kind: input, name: "replaceYouTube",
+ label: "Replace YouTube links with Invidious (blank to disable)",
+ defaultInput: "invidio.us", placeholder: "Invidious hostname")
+ ],
+
+ "Media": @[
+ Pref(kind: checkbox, name: "mp4Playback",
+ label: "Enable mp4 video playback",
+ defaultState: true),
+
+ Pref(kind: checkbox, name: "hlsPlayback",
+ label: "Enable hls video streaming (requires JavaScript)",
+ defaultState: false),
+
+ Pref(kind: checkbox, name: "muteVideos",
+ label: "Mute videos by default",
+ defaultState: false),
+
+ Pref(kind: checkbox, name: "autoplayGifs", label: "Autoplay gifs",
+ defaultState: true)
+ ],
+
+ "Display": @[
+ Pref(kind: checkbox, name: "hideTweetStats",
+ label: "Hide tweet stats (replies, retweets, likes)",
+ defaultState: false),
+
+ Pref(kind: checkbox, name: "hideBanner", label: "Hide profile banner",
+ defaultState: false),
+
+ Pref(kind: checkbox, name: "stickyProfile",
+ label: "Make profile sidebar stick to top",
+ defaultState: true)
+ ]
+}.toTable
+
+iterator allPrefs*(): Pref =
+ for k, v in prefList:
+ for pref in v:
+ yield pref
+
+macro genDefaultPrefs*(): untyped =
+ result = nnkObjConstr.newTree(ident("Prefs"))
+
+ for pref in allPrefs():
+ let default =
+ case pref.kind
+ of checkbox: newLit(pref.defaultState)
+ of select: newLit(pref.defaultOption)
+ of input: newLit(pref.defaultInput)
+
+ result.add nnkExprColonExpr.newTree(ident(pref.name), default)
+
+macro genUpdatePrefs*(): untyped =
+ result = nnkStmtList.newTree()
+
+ for pref in allPrefs():
+ let ident = ident(pref.name)
+ let value = nnkPrefix.newTree(ident("@"), newLit(pref.name))
+
+ case pref.kind
+ of checkbox:
+ result.add quote do: prefs.`ident` = `value` == "on"
+ of input:
+ result.add quote do: prefs.`ident` = xmltree.escape(strip(`value`))
+ of select:
+ let options = pref.options
+ let default = pref.defaultOption
+ result.add quote do:
+ if `value` in `options`: prefs.`ident` = `value`
+ else: prefs.`ident` = `default`
+
+ result.add quote do:
+ cache(prefs)
+
diff --git a/src/types.nim b/src/types.nim
index 40a795c..611b361 100644
--- a/src/types.nim
+++ b/src/types.nim
@@ -1,5 +1,6 @@
import times, sequtils, options
import norm/sqlite
+import prefs_impl
export sqlite, options
@@ -22,25 +23,17 @@ db("cache.db", "", "", ""):
tweets*: string
likes*: string
media*: string
- verified* {.
- dbType: "STRING",
- parseIt: parseBool(it.s)
- formatIt: $it
- .}: bool
- protected* {.
- dbType: "STRING",
- parseIt: parseBool(it.s)
- formatIt: $it
- .}: bool
+ verified*: bool
+ protected*: bool
joinDate* {.
- dbType: "INTEGER",
- parseIt: it.i.fromUnix(),
- formatIt: it.toUnix()
+ dbType: "INTEGER"
+ parseIt: it.i.fromUnix()
+ formatIt: dbValue(it.toUnix())
.}: Time
updated* {.
- dbType: "INTEGER",
- parseIt: it.i.fromUnix(),
- formatIt: getTime().toUnix()
+ dbType: "INTEGER"
+ parseIt: it.i.fromUnix()
+ formatIt: dbValue(getTime().toUnix())
.}: Time
Video* = object
@@ -50,16 +43,23 @@ db("cache.db", "", "", ""):
url*: string
thumb*: string
views*: string
+ available*: bool
playbackType* {.
- dbType: "STRING",
- parseIt: parseEnum[VideoType](it.s),
- formatIt: $it,
+ dbType: "STRING"
+ parseIt: parseEnum[VideoType](it.s)
+ formatIt: dbValue($it)
.}: VideoType
- available* {.
- dbType: "STRING",
- parseIt: parseBool(it.s)
- formatIt: $it
- .}: bool
+
+ Prefs* = object
+ hlsPlayback*: bool
+ mp4Playback*: bool
+ muteVideos*: bool
+ autoplayGifs*: bool
+ hideTweetStats*: bool
+ hideBanner*: bool
+ stickyProfile*: bool
+ replaceYouTube*: string
+ replaceTwitter*: string
type
QueryKind* = enum
@@ -169,6 +169,7 @@ type
Config* = ref object
address*: string
port*: int
+ useHttps*: bool
title*: string
staticDir*: string
cacheDir*: string
diff --git a/src/views/general.nim b/src/views/general.nim
index 305c9d9..8639053 100644
--- a/src/views/general.nim
+++ b/src/views/general.nim
@@ -1,6 +1,7 @@
import karax/[karaxdsl, vdom]
-import ../utils
+import renderutils
+import ../utils, ../types
const doctype = "\n"
@@ -13,14 +14,15 @@ proc renderNavbar*(title: string): VNode =
a(href="/"): img(class="site-logo", src="/logo.png")
tdiv(class="item right"):
- a(class="site-about", href="/about"): text "🛈"
- a(class="site-settings", href="/settings"): text "⚙"
+ icon "info-circled", title="About", href="/about"
+ icon "cog-2", title="Preferences", href="/settings"
proc renderMain*(body: VNode; title="Nitter"; titleText=""; desc="";
`type`="article"; video=""; images: seq[string] = @[]): string =
let node = buildHtml(html(lang="en")):
head:
- link(rel="stylesheet", `type`="text/css", href="/style.css")
+ link(rel="stylesheet", `type`="text/css", href="/css/style.css")
+ link(rel="stylesheet", `type`="text/css", href="/css/fontello.css")
title:
if titleText.len > 0:
@@ -53,12 +55,12 @@ proc renderSearch*(): VNode =
tdiv(class="search-panel"):
form(`method`="post", action="search"):
input(`type`="text", name="query", autofocus="", placeholder="Enter usernames...")
- button(`type`="submit"): text "🔎"
+ button(`type`="submit"): icon "search"
proc renderError*(error: string): VNode =
buildHtml(tdiv(class="panel")):
tdiv(class="error-panel"):
span: text error
-proc showError*(error: string; title: string): string =
- renderMain(renderError(error), title=title, titleText="Error")
+proc showError*(error, title: string): string =
+ renderMain(renderError(error), title, "Error")
diff --git a/src/views/preferences.nim b/src/views/preferences.nim
new file mode 100644
index 0000000..47d88ee
--- /dev/null
+++ b/src/views/preferences.nim
@@ -0,0 +1,67 @@
+import tables, macros, strformat, xmltree
+import karax/[karaxdsl, vdom, vstyles]
+
+import ../types, ../prefs_impl
+
+proc genCheckbox(pref, label: string; state: bool): VNode =
+ buildHtml(tdiv(class="pref-group")):
+ label(class="checkbox-container"):
+ text label
+ if state: input(name=pref, `type`="checkbox", checked="")
+ else: input(name=pref, `type`="checkbox")
+ span(class="checkbox")
+
+proc genSelect(pref, label, state: string; options: seq[string]): VNode =
+ buildHtml(tdiv(class="pref-group")):
+ label(`for`=pref): text label
+ select(name=pref):
+ for opt in options:
+ if opt == state:
+ option(value=opt, selected=""): text opt
+ else:
+ option(value=opt): text opt
+
+proc genInput(pref, label, state, placeholder: string): VNode =
+ let s = xmltree.escape(state)
+ let p = xmltree.escape(placeholder)
+ buildHtml(tdiv(class="pref-group pref-input")):
+ label(`for`=pref): text label
+ verbatim &""
+
+macro renderPrefs*(): untyped =
+ result = nnkCall.newTree(
+ ident("buildHtml"), ident("tdiv"), nnkStmtList.newTree())
+
+ for header, options in prefList:
+ result[2].add nnkCall.newTree(
+ ident("legend"),
+ nnkStmtList.newTree(
+ nnkCommand.newTree(ident("text"), newLit(header))))
+
+ for pref in options:
+ let procName = ident("gen" & capitalizeAscii($pref.kind))
+ let state = nnkDotExpr.newTree(ident("prefs"), ident(pref.name))
+ var stmt = nnkStmtList.newTree(
+ nnkCall.newTree(procName, newLit(pref.name), newLit(pref.label), state))
+
+ case pref.kind
+ of checkbox: discard
+ of select: stmt[0].add newLit(pref.options)
+ of input: stmt[0].add newLit(pref.placeholder)
+
+ result[2].add stmt
+
+proc renderPreferences*(prefs: Prefs; path: string): VNode =
+ buildHtml(tdiv(class="preferences-container")):
+ fieldset(class="preferences"):
+ form(`method`="post", action="saveprefs"):
+ verbatim "" % path
+
+ renderPrefs()
+
+ button(`type`="submit", class="pref-submit"):
+ text "Save preferences"
+
+ form(`method`="post", action="resetprefs", class="pref-reset"):
+ button(`type`="submit"):
+ text "Reset preferences"
diff --git a/src/views/profile.nim b/src/views/profile.nim
index 1f55f79..e6b8318 100644
--- a/src/views/profile.nim
+++ b/src/views/profile.nim
@@ -1,8 +1,8 @@
import strutils, strformat
import karax/[karaxdsl, vdom, vstyles]
-import ../types, ../utils, ../formatters
import tweet, timeline, renderutils
+import ../types, ../utils, ../formatters
proc renderStat(num, class: string; text=""): VNode =
let t = if text.len > 0: text else: class
@@ -11,7 +11,7 @@ proc renderStat(num, class: string; text=""): VNode =
span(class="profile-stat-num"):
text if num.len == 0: "?" else: num
-proc renderProfileCard*(profile: Profile): VNode =
+proc renderProfileCard*(profile: Profile; prefs: Prefs): VNode =
buildHtml(tdiv(class="profile-card")):
a(class="profile-card-avatar", href=profile.getUserPic().getSigUrl("pic")):
genImg(profile.getUserpic("_200x200"))
@@ -23,21 +23,21 @@ proc renderProfileCard*(profile: Profile): VNode =
tdiv(class="profile-card-extra"):
if profile.bio.len > 0:
tdiv(class="profile-bio"):
- p: verbatim linkifyText(profile.bio)
+ p: verbatim linkifyText(profile.bio, prefs)
if profile.location.len > 0:
tdiv(class="profile-location"):
- span: text "📍 " & profile.location
+ span: icon "location", profile.location
if profile.website.len > 0:
tdiv(class="profile-website"):
span:
- text "🔗 "
+ icon "link"
linkText(profile.website)
tdiv(class="profile-joindate"):
span(title=getJoinDateFull(profile)):
- text "📅 " & getJoinDate(profile)
+ icon "calendar", getJoinDate(profile)
tdiv(class="profile-card-extra-links"):
ul(class="profile-statlist"):
@@ -50,7 +50,7 @@ proc renderPhotoRail(profile: Profile; photoRail: seq[GalleryPhoto]): VNode =
buildHtml(tdiv(class="photo-rail-card")):
tdiv(class="photo-rail-header"):
a(href=(&"/{profile.username}/media")):
- text &"🖼 {profile.media} Photos and videos"
+ icon "picture-1", $profile.media & " Photos and videos"
tdiv(class="photo-rail-grid"):
for i, photo in photoRail:
@@ -68,20 +68,22 @@ proc renderBanner(profile: Profile): VNode =
genImg(profile.banner)
proc renderProfile*(profile: Profile; timeline: Timeline;
- photoRail: seq[GalleryPhoto]): VNode =
+ photoRail: seq[GalleryPhoto]; prefs: Prefs): VNode =
buildHtml(tdiv(class="profile-tabs")):
- tdiv(class="profile-banner"):
- renderBanner(profile)
+ if not prefs.hideBanner:
+ tdiv(class="profile-banner"):
+ renderBanner(profile)
- tdiv(class="profile-tab"):
- renderProfileCard(profile)
+ let sticky = if prefs.stickyProfile: "sticky" else: "unset"
+ tdiv(class="profile-tab", style={position: sticky}):
+ renderProfileCard(profile, prefs)
if photoRail.len > 0:
renderPhotoRail(profile, photoRail)
tdiv(class="timeline-tab"):
- renderTimeline(timeline, profile.username, profile.protected)
+ renderTimeline(timeline, profile.username, profile.protected, prefs)
-proc renderMulti*(timeline: Timeline; usernames: string): VNode =
+proc renderMulti*(timeline: Timeline; usernames: string; prefs: Prefs): VNode =
buildHtml(tdiv(class="multi-timeline")):
tdiv(class="timeline-tab"):
- renderTimeline(timeline, usernames, false, multi=true)
+ renderTimeline(timeline, usernames, false, prefs, multi=true)
diff --git a/src/views/renderutils.nim b/src/views/renderutils.nim
index eab66cf..c42c76e 100644
--- a/src/views/renderutils.nim
+++ b/src/views/renderutils.nim
@@ -2,6 +2,18 @@ import karax/[karaxdsl, vdom, vstyles]
import ../types, ../utils
+proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode =
+ var c = "icon-" & icon
+ if class.len > 0: c = c & " " & class
+ buildHtml(tdiv(class="icon-container")):
+ if href.len > 0:
+ a(class=c, title=title, href=href)
+ else:
+ span(class=c, title=title)
+
+ if text.len > 0:
+ text " " & text
+
proc linkUser*(profile: Profile, class=""): VNode =
let
isName = "username" notin class
@@ -12,9 +24,10 @@ proc linkUser*(profile: Profile, class=""): VNode =
buildHtml(a(href=href, class=class, title=nameText)):
text nameText
if isName and profile.verified:
- span(class="icon verified-icon", title="Verified account"): text "✔"
+ icon "ok", class="verified-icon", title="Verified account"
if isName and profile.protected:
- span(class="icon protected-icon", title="Protected account"): text "🔒"
+ text " "
+ icon "lock-circled", title="Protected account"
proc genImg*(url: string; class=""): VNode =
buildHtml():
diff --git a/src/views/status.nim b/src/views/status.nim
index 06ab7a2..8b0b838 100644
--- a/src/views/status.nim
+++ b/src/views/status.nim
@@ -4,11 +4,11 @@ import karax/[karaxdsl, vdom]
import ../types
import tweet, renderutils
-proc renderReplyThread(thread: Thread): VNode =
+proc renderReplyThread(thread: Thread; prefs: Prefs): VNode =
buildHtml(tdiv(class="reply thread thread-line")):
for i, tweet in thread.tweets:
let last = (i == thread.tweets.high and thread.more == 0)
- renderTweet(tweet, index=i, last=last)
+ renderTweet(tweet, prefs, index=i, last=last)
if thread.more != 0:
let num = if thread.more != -1: $thread.more & " " else: ""
@@ -17,26 +17,26 @@ proc renderReplyThread(thread: Thread): VNode =
a(class="more-replies-text", title="Not implemented yet"):
text $num & "more " & reply
-proc renderConversation*(conversation: Conversation): VNode =
+proc renderConversation*(conversation: Conversation; prefs: Prefs): VNode =
let hasAfter = conversation.after != nil
buildHtml(tdiv(class="conversation", id="posts")):
tdiv(class="main-thread"):
if conversation.before != nil:
tdiv(class="before-tweet thread-line"):
for i, tweet in conversation.before.tweets:
- renderTweet(tweet, index=i)
+ renderTweet(tweet, prefs, index=i)
tdiv(class="main-tweet"):
let afterClass = if hasAfter: "thread thread-line" else: ""
- renderTweet(conversation.tweet, class=afterClass)
+ renderTweet(conversation.tweet, prefs, class=afterClass)
if hasAfter:
tdiv(class="after-tweet thread-line"):
let total = conversation.after.tweets.high
for i, tweet in conversation.after.tweets:
- renderTweet(tweet, index=i, total=total)
+ renderTweet(tweet, prefs, index=i, total=total)
if conversation.replies.len > 0:
tdiv(class="replies"):
for thread in conversation.replies:
- renderReplyThread(thread)
+ renderReplyThread(thread, prefs)
diff --git a/src/views/timeline.nim b/src/views/timeline.nim
index 726ee63..f9d0744 100644
--- a/src/views/timeline.nim
+++ b/src/views/timeline.nim
@@ -54,28 +54,28 @@ proc renderProtected(username: string): VNode =
h2: text "This account's tweets are protected."
p: text &"Only confirmed followers have access to @{username}'s tweets."
-proc renderThread(thread: seq[Tweet]): VNode =
+proc renderThread(thread: seq[Tweet]; prefs: Prefs): VNode =
buildHtml(tdiv(class="timeline-tweet thread-line")):
for i, threadTweet in thread.sortedByIt(it.time):
- renderTweet(threadTweet, "thread", index=i, total=thread.high)
+ renderTweet(threadTweet, prefs, class="thread", index=i, total=thread.high)
proc threadFilter(it: Tweet; tweetThread: string): bool =
it.retweet.isNone and it.reply.len == 0 and it.threadId == tweetThread
-proc renderTweets(timeline: Timeline): VNode =
+proc renderTweets(timeline: Timeline; prefs: Prefs): VNode =
buildHtml(tdiv(id="posts")):
var threads: seq[string]
for tweet in timeline.tweets:
if tweet.threadId in threads: continue
let thread = timeline.tweets.filterIt(threadFilter(it, tweet.threadId))
if thread.len < 2:
- renderTweet(tweet, "timeline-tweet")
+ renderTweet(tweet, prefs, class="timeline-tweet")
else:
- renderThread(thread)
+ renderThread(thread, prefs)
threads &= tweet.threadId
-proc renderTimeline*(timeline: Timeline; username: string;
- protected: bool; multi=false): VNode =
+proc renderTimeline*(timeline: Timeline; username: string; protected: bool;
+ prefs: Prefs; multi=false): VNode =
buildHtml(tdiv):
if multi:
tdiv(class="multi-header"):
@@ -91,7 +91,7 @@ proc renderTimeline*(timeline: Timeline; username: string;
elif timeline.tweets.len == 0:
renderNoneFound()
else:
- renderTweets(timeline)
+ renderTweets(timeline, prefs)
if timeline.hasMore or timeline.query.isSome:
renderOlder(timeline, username)
else:
diff --git a/src/views/tweet.nim b/src/views/tweet.nim
index 0fd5d9d..4800ee2 100644
--- a/src/views/tweet.nim
+++ b/src/views/tweet.nim
@@ -1,17 +1,18 @@
import strutils, sequtils
import karax/[karaxdsl, vdom, vstyles]
-import ../types, ../utils, ../formatters
import renderutils
+import ../types, ../utils, ../formatters
proc renderHeader(tweet: Tweet): VNode =
buildHtml(tdiv):
if tweet.retweet.isSome:
tdiv(class="retweet"):
- span: text "🔄 " & get(tweet.retweet).by & " retweeted"
+ span: icon "retweet-1", get(tweet.retweet).by & " retweeted"
+
if tweet.pinned:
tdiv(class="pinned"):
- span: text "📌 Pinned Tweet"
+ span: icon "pin", "Pinned Tweet"
tdiv(class="tweet-header"):
a(class="tweet-avatar", href=("/" & tweet.profile.username)):
@@ -44,26 +45,55 @@ proc renderAlbum(tweet: Tweet): VNode =
target="_blank", style={display: flex}):
genImg(photo)
-proc renderVideo(video: Video): VNode =
+proc isPlaybackEnabled(prefs: Prefs; video: Video): bool =
+ case video.playbackType
+ of mp4: prefs.mp4Playback
+ of m3u8, vmap: prefs.hlsPlayback
+
+proc renderVideoDisabled(video: Video): VNode =
+ buildHtml(tdiv):
+ img(src=video.thumb.getSigUrl("pic"))
+ tdiv(class="video-overlay"):
+ case video.playbackType
+ of mp4:
+ p: text "mp4 playback disabled in preferences"
+ of m3u8, vmap:
+ p: text "hls playback disabled in preferences"
+
+proc renderVideo(video: Video; prefs: Prefs): VNode =
buildHtml(tdiv(class="attachments")):
tdiv(class="gallery-video"):
tdiv(class="attachment video-container"):
- case video.playbackType
- of mp4:
- video(poster=video.thumb.getSigUrl("pic"), controls=""):
- source(src=video.url.getSigUrl("video"), `type`="video/mp4")
- of m3u8, vmap:
- video(poster=video.thumb.getSigUrl("pic"))
- tdiv(class="video-overlay"):
- p: text "Video playback not supported"
+ if prefs.isPlaybackEnabled(video):
+ let thumb = video.thumb.getSigUrl("pic")
+ let source = video.url.getSigUrl("video")
+ case video.playbackType
+ of mp4:
+ if prefs.muteVideos:
+ video(poster=thumb, controls="", muted=""):
+ source(src=source, `type`="video/mp4")
+ else:
+ video(poster=thumb, controls=""):
+ source(src=source, `type`="video/mp4")
+ of m3u8, vmap:
+ video(poster=thumb)
+ tdiv(class="video-overlay"):
+ p: text "Video playback not supported yet"
+ else:
+ renderVideoDisabled(video)
-proc renderGif(gif: Gif): VNode =
+proc renderGif(gif: Gif; prefs: Prefs): VNode =
buildHtml(tdiv(class="attachments media-gif")):
tdiv(class="gallery-gif", style=style(maxHeight, "unset")):
tdiv(class="attachment"):
- video(class="gif", poster=gif.thumb.getSigUrl("pic"),
- autoplay="", muted="", loop=""):
- source(src=gif.url.getSigUrl("video"), `type`="video/mp4")
+ let thumb = gif.thumb.getSigUrl("pic")
+ let url = gif.url.getSigUrl("video")
+ if prefs.autoplayGifs:
+ video(class="gif", poster=thumb, autoplay="", muted="", loop=""):
+ source(src=url, `type`="video/mp4")
+ else:
+ video(class="gif", poster=thumb, controls="", muted="", loop=""):
+ source(src=url, `type`="video/mp4")
proc renderPoll(poll: Poll): VNode =
buildHtml(tdiv(class="poll")):
@@ -80,22 +110,22 @@ proc renderPoll(poll: Poll): VNode =
proc renderCardImage(card: Card): VNode =
buildHtml(tdiv(class="card-image-container")):
tdiv(class="card-image"):
- img(src=get(card.image).getSigUrl("pic"))
+ img(src=getSigUrl(get(card.image), "pic"))
if card.kind == player:
tdiv(class="card-overlay"):
tdiv(class="card-overlay-circle"):
span(class="card-overlay-triangle")
-proc renderCard(card: Card): VNode =
+proc renderCard(card: Card; prefs: Prefs): VNode =
const largeCards = {summaryLarge, liveEvent, promoWebsite, promoVideo}
let large = if card.kind in largeCards: " large" else: ""
buildHtml(tdiv(class=("card" & large))):
- a(class="card-container", href=card.url):
+ a(class="card-container", href=replaceUrl(card.url, prefs)):
if card.image.isSome:
renderCardImage(card)
elif card.video.isSome:
- renderVideo(get(card.video))
+ renderVideo(get(card.video), prefs)
tdiv(class="card-content-container"):
tdiv(class="card-content"):
@@ -105,9 +135,9 @@ proc renderCard(card: Card): VNode =
proc renderStats(stats: TweetStats): VNode =
buildHtml(tdiv(class="tweet-stats")):
- span(class="tweet-stat"): text "💬 " & $stats.replies
- span(class="tweet-stat"): text "🔄 " & $stats.retweets
- span(class="tweet-stat"): text "👍 " & $stats.likes
+ span(class="tweet-stat"): icon "comment", $stats.replies
+ span(class="tweet-stat"): icon "retweet-1", $stats.retweets
+ span(class="tweet-stat"): icon "thumbs-up-alt", $stats.likes
proc renderReply(tweet: Tweet): VNode =
buildHtml(tdiv(class="replying-to")):
@@ -133,9 +163,9 @@ proc renderQuoteMedia(quote: Quote): VNode =
tdiv(class="quote-badge-text"): text quote.badge
elif quote.sensitive:
tdiv(class="quote-sensitive"):
- span(class="icon quote-sensitive-icon"): text "❗"
+ icon "attention", class="quote-sensitive-icon"
-proc renderQuote(quote: Quote): VNode =
+proc renderQuote(quote: Quote; prefs: Prefs): VNode =
if not quote.available:
return buildHtml(tdiv(class="quote unavailable")):
tdiv(class="unavailable-quote"):
@@ -155,13 +185,14 @@ proc renderQuote(quote: Quote): VNode =
renderReply(quote)
tdiv(class="quote-text"):
- verbatim linkifyText(quote.text)
+ verbatim linkifyText(quote.text, prefs)
if quote.hasThread:
- a(href=getLink(quote)):
+ a(class="show-thread", href=getLink(quote)):
text "Show this thread"
-proc renderTweet*(tweet: Tweet; class=""; index=0; total=(-1); last=false): VNode =
+proc renderTweet*(tweet: Tweet; prefs: Prefs; class="";
+ index=0; total=(-1); last=false): VNode =
var divClass = class
if index == total or last:
divClass = "thread-last " & class
@@ -181,24 +212,25 @@ proc renderTweet*(tweet: Tweet; class=""; index=0; total=(-1); last=false): VNod
renderReply(tweet)
tdiv(class="status-content media-body"):
- verbatim linkifyText(tweet.text)
+ verbatim linkifyText(tweet.text, prefs)
if tweet.quote.isSome:
- renderQuote(tweet.quote.get())
+ renderQuote(tweet.quote.get(), prefs)
if tweet.card.isSome:
- renderCard(tweet.card.get())
+ renderCard(tweet.card.get(), prefs)
elif tweet.photos.len > 0:
renderAlbum(tweet)
elif tweet.video.isSome:
- renderVideo(tweet.video.get())
+ renderVideo(tweet.video.get(), prefs)
elif tweet.gif.isSome:
- renderGif(tweet.gif.get())
+ renderGif(tweet.gif.get(), prefs)
elif tweet.poll.isSome:
renderPoll(tweet.poll.get())
- renderStats(tweet.stats)
+ if not prefs.hideTweetStats:
+ renderStats(tweet.stats)
if tweet.hasThread and "timeline" in class:
- a(href=getLink(tweet)):
+ a(class="show-thread", href=getLink(tweet)):
text "Show this thread"
diff --git a/tests/base.py b/tests/base.py
index 1c2ade1..709df72 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -39,7 +39,7 @@ class Tweet(object):
class Profile(object):
fullname = '.profile-card-fullname'
username = '.profile-card-username'
- protected = '.protected-icon'
+ protected = '.icon-lock-circled'
verified = '.verified-icon'
banner = '.profile-banner'
bio = '.profile-bio'
diff --git a/tests/test_card.py b/tests/test_card.py
index 8c2b4d6..fba1041 100644
--- a/tests/test_card.py
+++ b/tests/test_card.py
@@ -6,68 +6,68 @@ card = [
['voidtarget/status/1133028231672582145',
'sinkingsugar/nimqt-example',
'A sample of a Qt app written using mostly nim. Contribute to sinkingsugar/nimqt-example development by creating an account on GitHub.',
- 'github.com', '-tb6lD-A', False],
+ 'github.com', False],
['Bountysource/status/1141879700639215617',
'$1,000 Bounty on kivy/plyer',
'Automation and Screen Reader Support',
- 'bountysource.com', 'TF5vo84K', False],
+ 'bountysource.com', False],
['lorenlugosch/status/1115440394148487168',
'lorenlugosch/pretrain_speech_model',
'Speech Model Pre-training for End-to-End Spoken Language Understanding - lorenlugosch/pretrain_speech_model',
- 'github.com', 'VwMnYBVh', False],
+ 'github.com', False],
['PyTorch/status/1123379369672450051',
'PyTorch',
'An open source deep learning platform that provides a seamless path from research prototyping to production deployment.',
- 'pytorch.org', 'lAc4aESh', False],
+ 'pytorch.org', False],
['Thom_Wolf/status/1122466524860702729',
'pytorch/fairseq',
'Facebook AI Research Sequence-to-Sequence Toolkit written in Python. - pytorch/fairseq',
- 'github.com', '1SVn24P6', False],
+ 'github.com', False],
['TheTwoffice/status/558685306090946561',
'Eternity: a moment standing still forever…',
'- James Montgomery. | facebook | 500px | ferpectshotz | I dusted off this one from my old archives, it was taken while I was living in mighty new York city working at Wall St. I think this was the 11...',
- 'flickr.com', '1LT6fSLU', True],
+ 'flickr.com', True],
['nim_lang/status/1136652293510717440',
'Version 0.20.0 released',
'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!',
- 'nim-lang.org', 'Q0aJrdMZ', True],
+ 'nim-lang.org', True],
['Tesla/status/1141041022035623936',
'Experience the Tesla Arcade',
'',
- 'www.tesla.com', '40H36baw', True],
+ 'www.tesla.com', True],
['mobile_test/status/490378953744318464',
'Nantasket Beach',
'Rocks on the beach.',
- '500px.com', 'FVUU4YDwN', True],
+ '500px.com', True],
['voidtarget/status/1094632512926605312',
'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too)',
'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too) - obsplugin.nim',
- 'gist.github.com', '37n4WuBF', True],
+ 'gist.github.com', True],
['AdsAPI/status/1110272721005367296',
'Conversation Targeting',
'',
- 'view.highspot.com', 'FrVMLWJH', True],
+ 'view.highspot.com', True],
['FluentAI/status/1116417904831029248',
'Amazon’s Alexa isn’t just AI — thousands of humans are listening',
'One of the only ways to improve Alexa is to have human beings check it for errors',
- 'theverge.com', 'HOW73fOB', True]
+ 'theverge.com', True]
]
no_thumb = [
['nim_lang/status/1082989146040340480',
'Nim in 2018: A short recap',
- 'Posted in r/programming by u/miran1 • 38 points and 46 comments',
+ 'Posted in r/programming by u/miran1 • 36 points and 46 comments',
'reddit.com'],
['brent_p/status/1088857328680488961',
@@ -80,17 +80,17 @@ playable = [
['nim_lang/status/1118234460904919042',
'Nim development blog 2019-03',
'Arne (aka Krux02) * debugging: * improved nim-gdb, $ works, framefilter * alias for --debugger:native: -g * bugs: * forwarding of .pure. * sizeof union * fea...',
- 'youtube.com', 'rJkABhGF'],
+ 'youtube.com'],
['nim_lang/status/1121090879823986688',
'Nim - First natively compiled language w/ hot code-reloading at...',
'#nim #c++ #ACCUConf Nim is a statically typed systems and applications programming language which offers perhaps some of the most powerful metaprogramming ca...',
- 'youtube.com', 'FuFgnQ9PA'],
+ 'youtube.com'],
['lele/status/819930645145288704',
'Eurocrash presents Open Decks - emerging dj #4: E-Musik',
"OPEN DECKS is Eurocrash's new project about discovering new and emerging dj talents. Every selected dj will have the chance to perform the first dj-set in front of an actual audience. The best dj...",
- 'mixcloud.com', 'FdM8jyi04']
+ 'mixcloud.com']
]
promo = [
@@ -106,12 +106,12 @@ promo = [
class CardTest(BaseTestCase):
@parameterized.expand(card)
- def test_card(self, tweet, title, description, destination, image, large):
+ def test_card(self, tweet, title, description, destination, large):
self.open_nitter(tweet)
card = Card(Conversation.main + " ")
self.assert_text(title, card.title)
self.assert_text(destination, card.destination)
- self.assertIn(image, self.get_image_url(card.image + ' img'))
+ self.assertIn('_img', self.get_image_url(card.image + ' img'))
if len(description) > 0:
self.assert_text(description, card.description)
if large:
@@ -129,12 +129,12 @@ class CardTest(BaseTestCase):
self.assert_text(description, card.description)
@parameterized.expand(playable)
- def test_card_playable(self, tweet, title, description, destination, image):
+ def test_card_playable(self, tweet, title, description, destination):
self.open_nitter(tweet)
card = Card(Conversation.main + " ")
self.assert_text(title, card.title)
self.assert_text(destination, card.destination)
- self.assertIn(image, self.get_image_url(card.image + ' img'))
+ self.assertIn('_img', self.get_image_url(card.image + ' img'))
self.assert_element_visible('.card-overlay')
if len(description) > 0:
self.assert_text(description, card.description)
diff --git a/tests/test_profile.py b/tests/test_profile.py
index 6441a7a..15c5240 100644
--- a/tests/test_profile.py
+++ b/tests/test_profile.py
@@ -4,15 +4,15 @@ from parameterized import parameterized
profiles = [
['mobile_test', 'Test account',
'Test Account. test test Testing username with @mobile_test_2 and a #hashtag',
- '📍 San Francisco, CA', '🔗 example.com/foobar', '📅 Joined October 2009', '100'],
- ['mobile_test_2', 'mobile test 2', '', '', '', '📅 Joined January 2011', '13']
+ 'San Francisco, CA', 'example.com/foobar', 'Joined October 2009', '100'],
+ ['mobile_test_2', 'mobile test 2', '', '', '', 'Joined January 2011', '13']
]
verified = [['jack'], ['elonmusk']]
protected = [
- ['mobile_test_7', 'mobile test 7🔒', ''],
- ['Poop', 'Randy🔒', 'Social media fanatic.']
+ ['mobile_test_7', 'mobile test 7', ''],
+ ['Poop', 'Randy', 'Social media fanatic.']
]
invalid = [['thisprofiledoesntexist'], ['%']]
@@ -39,7 +39,7 @@ class ProfileTest(BaseTestCase):
(location, Profile.location),
(website, Profile.website),
(joinDate, Profile.joinDate),
- (f"🖼 {mediaCount} Photos and videos", Profile.mediaCount)
+ (mediaCount + " Photos and videos", Profile.mediaCount)
]
for text, selector in tests:
diff --git a/tests/test_tweet.py b/tests/test_tweet.py
index fc3eedd..8520603 100644
--- a/tests/test_tweet.py
+++ b/tests/test_tweet.py
@@ -16,7 +16,7 @@ timeline = [
]
status = [
- [20, 'jack 🌍🌏🌎✔', 'jack', '21 Mar 2006', 'just setting up my twttr'],
+ [20, 'jack 🌍🌏🌎', 'jack', '21 Mar 2006', 'just setting up my twttr'],
[134849778302464000, 'The Twoffice', 'TheTwoffice', '10 Nov 2011', 'test'],
[105685475985080322, 'The Twoffice', 'TheTwoffice', '22 Aug 2011', 'regular tweet'],
[572593440719912960, 'Test account', 'mobile_test', '2 Mar 2015', 'testing test']
@@ -77,7 +77,7 @@ emoji = [
retweet = [
[7, 'mobile_test_2', 'mobile test 2', 'Test account', '@mobile_test', '1234'],
- [3, 'mobile_test_8', 'mobile test 8', 'jack 🌍🌏🌎✔', '@jack', 'twttr']
+ [3, 'mobile_test_8', 'mobile test 8', 'jack 🌍🌏🌎', '@jack', 'twttr']
]
reply = [
diff --git a/tests/test_tweet_media.py b/tests/test_tweet_media.py
index a7e0d37..95f2aa7 100644
--- a/tests/test_tweet_media.py
+++ b/tests/test_tweet_media.py
@@ -92,7 +92,7 @@ class MediaTest(BaseTestCase):
self.assert_element_visible(Media.container)
self.assert_element_visible(Media.video)
- video_thumb = self.get_attribute('video', 'poster')
+ video_thumb = self.get_attribute(Media.video + ' img', 'src')
self.assertIn(thumb, video_thumb)
@parameterized.expand(gallery)