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 @@ + + + +Copyright (C) 2019 by original authors @ fontello.com + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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)