From 966b3d5d62f923cbb72ddc5e96f19cd905f5c751 Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 13 Aug 2019 19:44:29 +0200 Subject: [PATCH 01/25] Add client preferences --- public/style.css | 44 +++++++++++++++++- src/cache.nim | 8 ++-- src/config.nim | 2 +- src/nitter.nim | 78 +++++++++++++++++++++----------- src/prefs.nim | 95 +++++++++++++++++++++++++++++++++++++++ src/types.nim | 24 +++++----- src/views/general.nim | 10 ++--- src/views/preferences.nim | 69 ++++++++++++++++++++++++++++ src/views/profile.nim | 8 ++-- src/views/status.nim | 14 +++--- src/views/timeline.nim | 16 +++---- src/views/tweet.nim | 43 +++++++++++------- 12 files changed, 329 insertions(+), 82 deletions(-) create mode 100644 src/prefs.nim create mode 100644 src/views/preferences.nim diff --git a/public/style.css b/public/style.css index 85fa05e..9fa8570 100644 --- a/public/style.css +++ b/public/style.css @@ -277,7 +277,7 @@ nav { overflow: hidden; } -video { +video, .video-container img { height: 100%; width: 100%; } @@ -1073,3 +1073,45 @@ video { .poll-info { color: #868687; } + +.preferences-container { + max-width: 600px; + margin: 0 auto; + width: 100%; +} + +.preferences { + background-color: #222222; +} + +fieldset { + margin: .35em 0 .75em; + border: 0; +} + +legend { + width: 100%; + padding: .6em 0 .3em 0; + margin-bottom: .2em; + border: 0; + border-bottom: 1px solid #888888; + font-size: 16px; +} + +.pref-group { + margin: .2em; 0; +} + +.pref-submit:hover { + background-color: #a8a8a8; +} + +.pref-submit { + background-color: #e2e2e2; + color: #000; + border: none; + border-radius: 2px; + padding: 3px 6px; + margin-left: 6px; + margin-top: 4px; +} 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..7c2bbdf 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) diff --git a/src/nitter.nim b/src/nitter.nim index a2dea9a..a406b49 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -3,13 +3,14 @@ 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 +35,41 @@ 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, prefs, title=cfg.title, titleText=pageTitle(profile), + desc=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, prefs, title=cfg.title, titleText="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 Http404, showError("User \"" & @"name" & "\" not found", cfg.title, prefs) resp timeline +proc getCookiePrefs(request: Request): Prefs = + getPrefs(request.cookies.getOrDefault("preferences")) + setProfileCacheTime(cfg.profileCacheTime) settings: @@ -70,58 +79,76 @@ settings: routes: get "/": - resp renderMain(renderSearch(), title=cfg.title) + let prefs = getCookiePrefs(request) + resp renderMain(renderSearch(), prefs, title=cfg.title) post "/search": if @"query".len == 0: - resp Http404, showError("Please enter a username.", cfg.title) + resp Http404, showError("Please enter a username.", cfg.title, + getCookiePrefs(request)) redirect("/" & @"query") + post "/saveprefs": + var prefs = getCookiePrefs(request) + genUpdatePrefs() + setCookie("preferences", $prefs.id, daysForward(360)) + redirect("/settings") + + get "/settings": + let prefs = getCookiePrefs(request) + resp renderMain(renderPreferences(prefs), prefs, title=cfg.title, titleText="Preferences") + get "/@name/?": cond '.' notin @"name" - respTimeline(await showTimeline(@"name", @"after", none(Query))) + let prefs = getCookiePrefs(request) + respTimeline(await showTimeline(@"name", @"after", none(Query), prefs)) get "/@name/search": cond '.' notin @"name" + let prefs = getCookiePrefs(request) let query = initQuery(@"filter", @"include", @"not", @"sep", @"name") - respTimeline(await showTimeline(@"name", @"after", some(query))) + respTimeline(await showTimeline(@"name", @"after", some(query), prefs)) get "/@name/replies": cond '.' notin @"name" - respTimeline(await showTimeline(@"name", @"after", some(getReplyQuery(@"name")))) + let prefs = getCookiePrefs(request) + respTimeline(await showTimeline(@"name", @"after", some(getReplyQuery(@"name")), prefs)) get "/@name/media": cond '.' notin @"name" - respTimeline(await showTimeline(@"name", @"after", some(getMediaQuery(@"name")))) + let prefs = getCookiePrefs(request) + respTimeline(await showTimeline(@"name", @"after", some(getMediaQuery(@"name")), prefs)) get "/@name/status/@id": cond '.' notin @"name" + let prefs = getCookiePrefs(request) let conversation = await getTweet(@"name", @"id", getAgent()) if conversation == nil or conversation.tweet.id.len == 0: - resp Http404, showError("Tweet not found", cfg.title) + resp Http404, showError("Tweet not found", cfg.title, prefs) 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, + resp renderMain(html, prefs, title=cfg.title, titleText=title, desc=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, + resp renderMain(html, prefs, title=cfg.title, titleText=title, desc=desc, images = @[thumb], `type`="video", video=vidUrl) else: - resp renderMain(html, title=cfg.title, titleText=title, + resp renderMain(html, prefs, title=cfg.title, titleText=title, desc=desc, images=conversation.tweet.photos) get "/pic/@sig/@url": cond "http" in @"url" cond "twimg" in @"url" + let prefs = getCookiePrefs(request) let uri = parseUri(decodeUrl(@"url")) @@ -129,7 +156,7 @@ routes: filename = cfg.cacheDir / cleanFilename(path & uri.query) if getHmac($uri) != @"sig": - resp showError("Failed to verify signature", cfg.title) + resp showError("Failed to verify signature", cfg.title, prefs) if not existsDir(cfg.cacheDir): createDir(cfg.cacheDir) @@ -151,10 +178,11 @@ routes: get "/video/@sig/@url": cond "http" in @"url" cond "video.twimg" in @"url" + let prefs = getCookiePrefs(request) let url = decodeUrl(@"url") if getHmac(url) != @"sig": - resp showError("Failed to verify signature", cfg.title) + resp showError("Failed to verify signature", cfg.title, prefs) let client = newAsyncHttpClient() diff --git a/src/prefs.nim b/src/prefs.nim new file mode 100644 index 0000000..7e2f04f --- /dev/null +++ b/src/prefs.nim @@ -0,0 +1,95 @@ +import asyncdispatch, times, macros, tables +import types + +withCustomDb("prefs.db", "", "", ""): + try: + createTables() + except DbError: + discard + +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 + +const prefList*: Table[string, seq[Pref]] = { + "Media": @[ + Pref(kind: checkbox, name: "videoPlayback", + label: "Enable hls.js video playback (requires JavaScript)", + defaultState: false), + + Pref(kind: checkbox, name: "autoplayGifs", label: "Autoplay gifs", + 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(): + result.add nnkExprColonExpr.newTree( + ident(pref.name), + case pref.kind + of checkbox: newLit(pref.defaultState) + of select: newLit(pref.defaultOption) + of input: newLit(pref.defaultInput)) + +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) + +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 select: + let options = pref.options + let default = pref.defaultOption + result.add quote do: + if `value` in `options`: prefs.`ident` = `value` + else: prefs.`ident` = `default` + of input: + result.add quote do: + prefs.`ident` = `value` + + result.add quote do: + cache(prefs) diff --git a/src/types.nim b/src/types.nim index 40a795c..aa20b59 100644 --- a/src/types.nim +++ b/src/types.nim @@ -23,23 +23,17 @@ db("cache.db", "", "", ""): likes*: string media*: string verified* {. - dbType: "STRING", - parseIt: parseBool(it.s) - formatIt: $it - .}: bool + dbType: "STRING", parseIt: parseBool(it.s), formatIt: $it.}: bool protected* {. - dbType: "STRING", - parseIt: parseBool(it.s) - formatIt: $it - .}: bool + dbType: "STRING", parseIt: parseBool(it.s), formatIt: $it.}: bool joinDate* {. - dbType: "INTEGER", - parseIt: it.i.fromUnix(), + dbType: "INTEGER" + parseIt: it.i.fromUnix() formatIt: it.toUnix() .}: Time updated* {. - dbType: "INTEGER", - parseIt: it.i.fromUnix(), + dbType: "INTEGER" + parseIt: it.i.fromUnix() formatIt: getTime().toUnix() .}: Time @@ -61,6 +55,12 @@ db("cache.db", "", "", ""): formatIt: $it .}: bool + Prefs* = object + videoPlayback* {. + dbType: "STRING", parseIt: parseBool(it.s), formatIt: $it.}: bool + autoplayGifs* {. + dbType: "STRING", parseIt: parseBool(it.s), formatIt: $it.}: bool + type QueryKind* = enum replies, media, multi, custom = "search" diff --git a/src/views/general.nim b/src/views/general.nim index 305c9d9..d07037b 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -1,6 +1,6 @@ import karax/[karaxdsl, vdom] -import ../utils +import ../utils, ../types const doctype = "\n" @@ -14,9 +14,9 @@ proc renderNavbar*(title: string): VNode = tdiv(class="item right"): a(class="site-about", href="/about"): text "๐Ÿ›ˆ" - a(class="site-settings", href="/settings"): text "โš™" + a(class="site-prefs", href="/settings"): text "โš™" -proc renderMain*(body: VNode; title="Nitter"; titleText=""; desc=""; +proc renderMain*(body: VNode; prefs: Prefs; title="Nitter"; titleText=""; desc=""; `type`="article"; video=""; images: seq[string] = @[]): string = let node = buildHtml(html(lang="en")): head: @@ -60,5 +60,5 @@ proc renderError*(error: string): VNode = tdiv(class="error-panel"): span: text error -proc showError*(error: string; title: string): string = - renderMain(renderError(error), title=title, titleText="Error") +proc showError*(error: string; title: string; prefs: Prefs): string = + renderMain(renderError(error), prefs, title=title, titleText="Error") diff --git a/src/views/preferences.nim b/src/views/preferences.nim new file mode 100644 index 0000000..d71916f --- /dev/null +++ b/src/views/preferences.nim @@ -0,0 +1,69 @@ +import tables, macros +import karax/[karaxdsl, vdom, vstyles] + +import ../types, ../prefs + +proc genCheckbox(pref: string; label: string; state: bool): VNode = + buildHtml(tdiv(class="pref-group")): + if state: + input(name=pref, `type`="checkbox", checked="") + else: + input(name=pref, `type`="checkbox") + label(`for`=pref): text label + +proc genSelect(pref: string; label: string; options: seq[string]; state: string): VNode = + buildHtml(tdiv(class="pref-group")): + select(name=pref): + for opt in options: + if opt == state: + option(value=opt, selected=""): text opt + else: + option(value=opt): text opt + label(`for`=pref): text label + +proc genInput(pref: string; label: string; placeholder, state: string): VNode = + buildHtml(tdiv(class="pref-group")): + input(name=pref, `type`="text", placeholder=placeholder, value=state) + label(`for`=pref): text label + +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 name = newLit(pref.name) + let label = newLit(pref.label) + let field = ident(pref.name) + case pref.kind + of checkbox: + result[2].add nnkStmtList.newTree( + nnkCall.newTree( + ident("genCheckbox"), name, label, + nnkDotExpr.newTree(ident("prefs"), field))) + of select: + let options = newLit(pref.options) + result[2].add nnkStmtList.newTree( + nnkCall.newTree( + ident("genSelect"), name, label, options, + nnkDotExpr.newTree(ident("prefs"), field))) + of input: + let placeholder = newLit(pref.placeholder) + result[2].add nnkStmtList.newTree( + nnkCall.newTree( + ident("genInput"), name, label, placeholder, + nnkDotExpr.newTree(ident("prefs"), field))) + +proc renderPreferences*(prefs: Prefs): VNode = + buildHtml(tdiv(class="preferences-container")): + form(class="preferences", `method`="post", action="saveprefs"): + fieldset: + renderPrefs() + + button(`type`="submit", class="pref-submit"): + text "Save preferences" diff --git a/src/views/profile.nim b/src/views/profile.nim index 1f55f79..45f700a 100644 --- a/src/views/profile.nim +++ b/src/views/profile.nim @@ -68,7 +68,7 @@ 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) @@ -79,9 +79,9 @@ proc renderProfile*(profile: Profile; timeline: Timeline; 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/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..d4ed0ea 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -44,26 +44,38 @@ proc renderAlbum(tweet: Tweet): VNode = target="_blank", style={display: flex}): genImg(photo) -proc renderVideo(video: Video): VNode = +proc renderVideo(video: Video; prefs: Prefs): VNode = buildHtml(tdiv(class="attachments")): tdiv(class="gallery-video"): tdiv(class="attachment video-container"): + let thumb = video.thumb.getSigUrl("pic") case video.playbackType of mp4: - video(poster=video.thumb.getSigUrl("pic"), controls=""): + video(poster=thumb, 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.videoPlayback: + video(poster=thumb) + tdiv(class="video-overlay"): + p: text "Video playback not supported yet" + else: + img(src=thumb) + tdiv(class="video-overlay"): + p: text "Video playback disabled" -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")): @@ -86,7 +98,7 @@ proc renderCardImage(card: Card): VNode = 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: "" @@ -95,7 +107,7 @@ proc renderCard(card: Card): VNode = 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"): @@ -161,7 +173,8 @@ proc renderQuote(quote: Quote): VNode = a(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 @@ -187,13 +200,13 @@ proc renderTweet*(tweet: Tweet; class=""; index=0; total=(-1); last=false): VNod renderQuote(tweet.quote.get()) 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()) From 0b52f4963cad3c303ad6b71f14fa2a29d4ba2af3 Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 13 Aug 2019 19:45:02 +0200 Subject: [PATCH 02/25] Fix video endpoint not closing client --- src/nitter.nim | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/nitter.nim b/src/nitter.nim index a406b49..8b29c7d 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -184,11 +184,10 @@ routes: if getHmac(url) != @"sig": resp showError("Failed to verify signature", cfg.title, prefs) - 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() From c834d418e2a28be2f03c71ac518743bebb24431d Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 13 Aug 2019 19:45:19 +0200 Subject: [PATCH 03/25] Fix wrong getVideo arguments --- src/api.nim | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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() From 7eb66d65aa0a2f7b3b0b05cc848d0eb3da5fbe5d Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 13 Aug 2019 21:21:54 +0200 Subject: [PATCH 04/25] Fix "show thread" link position on media posts --- public/style.css | 4 ++++ src/views/tweet.nim | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/public/style.css b/public/style.css index 9fa8570..4b916ef 100644 --- a/public/style.css +++ b/public/style.css @@ -390,6 +390,10 @@ video, .video-container img { background-color: #282828; } +.show-thread { + display: block; +} + .multi-header { background-color: #161616; text-align: center; diff --git a/src/views/tweet.nim b/src/views/tweet.nim index d4ed0ea..2224763 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -170,7 +170,7 @@ proc renderQuote(quote: Quote): VNode = verbatim linkifyText(quote.text) if quote.hasThread: - a(href=getLink(quote)): + a(class="show-thread", href=getLink(quote)): text "Show this thread" proc renderTweet*(tweet: Tweet; prefs: Prefs; class=""; @@ -213,5 +213,5 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; class=""; 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" From 45d88da3d9a37061de60d9eeb8e8c5713f41b370 Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 13 Aug 2019 21:25:29 +0200 Subject: [PATCH 05/25] Add more preferences --- public/style.css | 2 +- src/prefs.nim | 13 +++++++++++++ src/types.nim | 21 ++++++++------------- src/views/profile.nim | 8 +++++--- src/views/tweet.nim | 3 ++- 5 files changed, 29 insertions(+), 18 deletions(-) diff --git a/public/style.css b/public/style.css index 4b916ef..db44f47 100644 --- a/public/style.css +++ b/public/style.css @@ -386,6 +386,7 @@ video, .video-container img { padding: 0 2em; line-height: 2em; } + .show-more a:hover { background-color: #282828; } @@ -441,7 +442,6 @@ video, .video-container img { text-align: left; vertical-align: top; max-width: 32%; - position: sticky; top: 50px; } diff --git a/src/prefs.nim b/src/prefs.nim index 7e2f04f..e46adb6 100644 --- a/src/prefs.nim +++ b/src/prefs.nim @@ -32,6 +32,19 @@ const prefList*: Table[string, seq[Pref]] = { 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 diff --git a/src/types.nim b/src/types.nim index aa20b59..8787fc0 100644 --- a/src/types.nim +++ b/src/types.nim @@ -22,10 +22,8 @@ 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* {.dbType: "STRING", parseIt: parseBool(it.s), formatIt: $it.}: bool + protected* {.dbType: "STRING", parseIt: parseBool(it.s), formatIt: $it.}: bool joinDate* {. dbType: "INTEGER" parseIt: it.i.fromUnix() @@ -49,17 +47,14 @@ db("cache.db", "", "", ""): parseIt: parseEnum[VideoType](it.s), formatIt: $it, .}: VideoType - available* {. - dbType: "STRING", - parseIt: parseBool(it.s) - formatIt: $it - .}: bool + available* {.dbType: "STRING", parseIt: parseBool(it.s) formatIt: $it.}: bool Prefs* = object - videoPlayback* {. - dbType: "STRING", parseIt: parseBool(it.s), formatIt: $it.}: bool - autoplayGifs* {. - dbType: "STRING", parseIt: parseBool(it.s), formatIt: $it.}: bool + videoPlayback* {.dbType: "STRING", parseIt: parseBool(it.s), formatIt: $it.}: bool + autoplayGifs* {.dbType: "STRING", parseIt: parseBool(it.s), formatIt: $it.}: bool + hideTweetStats* {.dbType: "STRING", parseIt: parseBool(it.s), formatIt: $it.}: bool + hideBanner* {.dbType: "STRING", parseIt: parseBool(it.s), formatIt: $it.}: bool + stickyProfile* {.dbType: "STRING", parseIt: parseBool(it.s), formatIt: $it.}: bool type QueryKind* = enum diff --git a/src/views/profile.nim b/src/views/profile.nim index 45f700a..c3c9358 100644 --- a/src/views/profile.nim +++ b/src/views/profile.nim @@ -70,10 +70,12 @@ proc renderBanner(profile: Profile): VNode = proc renderProfile*(profile: Profile; timeline: Timeline; 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"): + let sticky = if prefs.stickyProfile: "sticky" else: "unset" + tdiv(class="profile-tab", style={position: sticky}): renderProfileCard(profile) if photoRail.len > 0: renderPhotoRail(profile, photoRail) diff --git a/src/views/tweet.nim b/src/views/tweet.nim index 2224763..1ae2be4 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -210,7 +210,8 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; class=""; 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(class="show-thread", href=getLink(tweet)): From 768ec3632f8a3b1fcb4b61acb8fb2a9cc1f3515c Mon Sep 17 00:00:00 2001 From: Zed Date: Thu, 15 Aug 2019 04:00:40 +0200 Subject: [PATCH 06/25] Use custom icon font for a cleaner design --- public/css/fontello.css | 53 ++++++++++++++++++++++++++++++++++++ public/{ => css}/style.css | 50 ++++++++++++++-------------------- public/fonts/LICENSE.txt | 39 ++++++++++++++++++++++++++ public/fonts/fontello.eot | Bin 0 -> 8916 bytes public/fonts/fontello.svg | 46 +++++++++++++++++++++++++++++++ public/fonts/fontello.ttf | Bin 0 -> 8748 bytes public/fonts/fontello.woff | Bin 0 -> 5552 bytes public/fonts/fontello.woff2 | Bin 0 -> 4620 bytes src/views/general.nim | 10 ++++--- src/views/profile.nim | 10 +++---- src/views/renderutils.nim | 17 ++++++++++-- src/views/tweet.nim | 16 +++++------ 12 files changed, 192 insertions(+), 49 deletions(-) create mode 100644 public/css/fontello.css rename public/{ => css}/style.css (97%) create mode 100644 public/fonts/LICENSE.txt create mode 100644 public/fonts/fontello.eot create mode 100644 public/fonts/fontello.svg create mode 100644 public/fonts/fontello.ttf create mode 100644 public/fonts/fontello.woff create mode 100644 public/fonts/fontello.woff2 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 97% rename from public/style.css rename to public/css/style.css index db44f47..a633117 100644 --- a/public/style.css +++ b/public/css/style.css @@ -107,29 +107,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 { @@ -215,14 +205,14 @@ nav { height: 35px; } -.site-about { - font-size: 17px; - padding-right: 2px; - margin-top: -0.75px; +.item.right a { + font-size: 16px; + padding-left: 4px; } -.site-settings { - font-size: 18px; +.item.right a:hover { + color: #ffaca0; + text-decoration: unset; } .attachments { @@ -902,12 +892,8 @@ video, .video-container img { } .quote-sensitive-icon { - font-size: 25px; - width: 37px; - height: 32px; - background-color: #4e4e4e; - padding-bottom: 5px; - margin: 0; + font-size: 40px; + color: #909090; } .card { @@ -1119,3 +1105,7 @@ legend { margin-left: 6px; margin-top: 4px; } + +.icon-container { + display: inline; +} 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 0000000000000000000000000000000000000000..43d722ffd608bfa1fe7ffac8ff77b9e2231db2ba GIT binary patch literal 8916 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 aNN_kV+^`GW9{&#C`3+6_9V80eDf}-j%qRE& literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6d82a62b3e922b43ceb408f5866200372f015feb GIT binary patch literal 8748 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 P`x~0{J17+RP2qn5)lC)d literal 0 HcmV?d00001 diff --git a/public/fonts/fontello.woff b/public/fonts/fontello.woff new file mode 100644 index 0000000000000000000000000000000000000000..100da96cc2cc9d6da71729c8518da7621c36dfbe GIT binary patch literal 5552 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 CF)*zF literal 0 HcmV?d00001 diff --git a/public/fonts/fontello.woff2 b/public/fonts/fontello.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..56cd672662c0b825959ede0ec4b1064beda39df5 GIT binary patch literal 4620 zcmV+n67%hMPew8T0RR9101^xU4*&oF03s{^01>hP0RR9100000000000000000000 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 0: @@ -53,7 +55,7 @@ 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")): diff --git a/src/views/profile.nim b/src/views/profile.nim index c3c9358..513b0d9 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 @@ -27,17 +27,17 @@ proc renderProfileCard*(profile: Profile): VNode = 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: 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/tweet.nim b/src/views/tweet.nim index 1ae2be4..407dd4e 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)): @@ -70,7 +71,6 @@ proc renderGif(gif: Gif; prefs: Prefs): VNode = 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: @@ -117,9 +117,9 @@ proc renderCard(card: Card; prefs: Prefs): 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")): @@ -145,7 +145,7 @@ 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 = if not quote.available: From 84dfcd089a8fc8ebdbdbed4fb63db2ac22138478 Mon Sep 17 00:00:00 2001 From: Zed Date: Thu, 15 Aug 2019 04:07:01 +0200 Subject: [PATCH 07/25] Update tests --- tests/base.py | 2 +- tests/test_card.py | 16 ++++++++-------- tests/test_profile.py | 10 +++++----- tests/test_tweet.py | 4 ++-- tests/test_tweet_media.py | 2 +- 5 files changed, 17 insertions(+), 17 deletions(-) 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..ac7b545 100644 --- a/tests/test_card.py +++ b/tests/test_card.py @@ -11,12 +11,12 @@ card = [ ['Bountysource/status/1141879700639215617', '$1,000 Bounty on kivy/plyer', 'Automation and Screen Reader Support', - 'bountysource.com', 'TF5vo84K', False], + 'bountysource.com', '1161324818224078848', 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', '1161172194040246272', False], ['PyTorch/status/1123379369672450051', 'PyTorch', @@ -31,7 +31,7 @@ card = [ ['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', '161236662619389953', True], ['nim_lang/status/1136652293510717440', 'Version 0.20.0 released', @@ -51,7 +51,7 @@ card = [ ['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', '1160647657574076423', True], ['AdsAPI/status/1110272721005367296', 'Conversation Targeting', @@ -67,7 +67,7 @@ card = [ 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', '1161613174514290688'], ['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', '1161379576087568386'], ['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', '161048988763795457'] ] promo = [ 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) From 887961259484ff54bffa2a9ced9301bb6728a2d8 Mon Sep 17 00:00:00 2001 From: Zed Date: Thu, 15 Aug 2019 14:38:14 +0200 Subject: [PATCH 08/25] Simplify pref macros --- src/prefs.nim | 8 +++----- src/views/preferences.nim | 36 +++++++++++++----------------------- 2 files changed, 16 insertions(+), 28 deletions(-) diff --git a/src/prefs.nim b/src/prefs.nim index e46adb6..93549b8 100644 --- a/src/prefs.nim +++ b/src/prefs.nim @@ -92,17 +92,15 @@ macro genUpdatePrefs*(): untyped = case pref.kind of checkbox: - result.add quote do: - prefs.`ident` = `value` == "on" + result.add quote do: prefs.`ident` = `value` == "on" + of input: + result.add quote do: prefs.`ident` = `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` - of input: - result.add quote do: - prefs.`ident` = `value` result.add quote do: cache(prefs) diff --git a/src/views/preferences.nim b/src/views/preferences.nim index d71916f..8d65fc6 100644 --- a/src/views/preferences.nim +++ b/src/views/preferences.nim @@ -3,7 +3,7 @@ import karax/[karaxdsl, vdom, vstyles] import ../types, ../prefs -proc genCheckbox(pref: string; label: string; state: bool): VNode = +proc genCheckbox(pref, label: string; state: bool): VNode = buildHtml(tdiv(class="pref-group")): if state: input(name=pref, `type`="checkbox", checked="") @@ -11,7 +11,7 @@ proc genCheckbox(pref: string; label: string; state: bool): VNode = input(name=pref, `type`="checkbox") label(`for`=pref): text label -proc genSelect(pref: string; label: string; options: seq[string]; state: string): VNode = +proc genSelect(pref, label, state: string; options: seq[string]): VNode = buildHtml(tdiv(class="pref-group")): select(name=pref): for opt in options: @@ -21,7 +21,7 @@ proc genSelect(pref: string; label: string; options: seq[string]; state: string) option(value=opt): text opt label(`for`=pref): text label -proc genInput(pref: string; label: string; placeholder, state: string): VNode = +proc genInput(pref, label, state, placeholder: string): VNode = buildHtml(tdiv(class="pref-group")): input(name=pref, `type`="text", placeholder=placeholder, value=state) label(`for`=pref): text label @@ -37,27 +37,17 @@ macro renderPrefs*(): untyped = nnkCommand.newTree(ident("text"), newLit(header)))) for pref in options: - let name = newLit(pref.name) - let label = newLit(pref.label) - let field = ident(pref.name) + 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: - result[2].add nnkStmtList.newTree( - nnkCall.newTree( - ident("genCheckbox"), name, label, - nnkDotExpr.newTree(ident("prefs"), field))) - of select: - let options = newLit(pref.options) - result[2].add nnkStmtList.newTree( - nnkCall.newTree( - ident("genSelect"), name, label, options, - nnkDotExpr.newTree(ident("prefs"), field))) - of input: - let placeholder = newLit(pref.placeholder) - result[2].add nnkStmtList.newTree( - nnkCall.newTree( - ident("genInput"), name, label, placeholder, - nnkDotExpr.newTree(ident("prefs"), field))) + 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): VNode = buildHtml(tdiv(class="preferences-container")): From 93da24be85ea8a0f05339fb3b4702353033781d9 Mon Sep 17 00:00:00 2001 From: Zed Date: Thu, 15 Aug 2019 14:52:34 +0200 Subject: [PATCH 09/25] Workaround karax issue 61 --- src/views/preferences.nim | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/views/preferences.nim b/src/views/preferences.nim index 8d65fc6..d9cc199 100644 --- a/src/views/preferences.nim +++ b/src/views/preferences.nim @@ -1,4 +1,4 @@ -import tables, macros +import tables, macros, strformat, xmltree import karax/[karaxdsl, vdom, vstyles] import ../types, ../prefs @@ -22,8 +22,10 @@ proc genSelect(pref, label, state: string; options: seq[string]): VNode = label(`for`=pref): text label proc genInput(pref, label, state, placeholder: string): VNode = + let s = xmltree.escape(state) + let p = xmltree.escape(placeholder) buildHtml(tdiv(class="pref-group")): - input(name=pref, `type`="text", placeholder=placeholder, value=state) + verbatim &"" label(`for`=pref): text label macro renderPrefs*(): untyped = From 7dfbc16f4c2695bc1f122c2bf75f27e68b294d84 Mon Sep 17 00:00:00 2001 From: Zed Date: Thu, 15 Aug 2019 15:51:20 +0200 Subject: [PATCH 10/25] Add Invidious/Nitter link replacement preferences --- src/formatters.nim | 14 +++++++++++++- src/nitter.nim | 3 ++- src/prefs.nim | 14 ++++++++++++-- src/types.nim | 8 +++++--- src/views/profile.nim | 6 +++--- src/views/tweet.nim | 12 ++++++------ 6 files changed, 41 insertions(+), 16 deletions(-) diff --git a/src/formatters.nim b/src/formatters.nim index 9e27fe5..4a268c0 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"youtu(be.com|.be)" + twRegex = re"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,16 @@ 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 = + if prefs.replaceYouTube.len > 0: + return url.replace(ytRegex, prefs.replaceYouTube) + if prefs.replaceTwitter.len > 0: + return url.replace(twRegex, prefs.replaceTwitter) proc stripTwitterUrls*(text: string): string = result = text diff --git a/src/nitter.nim b/src/nitter.nim index 8b29c7d..45198bf 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -1,4 +1,5 @@ -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 diff --git a/src/prefs.nim b/src/prefs.nim index 93549b8..4fb9ef7 100644 --- a/src/prefs.nim +++ b/src/prefs.nim @@ -1,4 +1,4 @@ -import asyncdispatch, times, macros, tables +import asyncdispatch, times, macros, tables, xmltree import types withCustomDb("prefs.db", "", "", ""): @@ -25,6 +25,16 @@ type placeholder*: string const prefList*: Table[string, seq[Pref]] = { + "Privacy": @[ + Pref(kind: input, name: "replaceTwitter", + label: "Replace Twitter links with Nitter (blank to disable)", + defaultInput: "nitter.net", 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: "videoPlayback", label: "Enable hls.js video playback (requires JavaScript)", @@ -94,7 +104,7 @@ macro genUpdatePrefs*(): untyped = of checkbox: result.add quote do: prefs.`ident` = `value` == "on" of input: - result.add quote do: prefs.`ident` = `value` + result.add quote do: prefs.`ident` = xmltree.escape(strip(`value`)) of select: let options = pref.options let default = pref.defaultOption diff --git a/src/types.nim b/src/types.nim index 8787fc0..38ef3b5 100644 --- a/src/types.nim +++ b/src/types.nim @@ -43,9 +43,9 @@ db("cache.db", "", "", ""): thumb*: string views*: string playbackType* {. - dbType: "STRING", - parseIt: parseEnum[VideoType](it.s), - formatIt: $it, + dbType: "STRING" + parseIt: parseEnum[VideoType](it.s) + formatIt: $it .}: VideoType available* {.dbType: "STRING", parseIt: parseBool(it.s) formatIt: $it.}: bool @@ -55,6 +55,8 @@ db("cache.db", "", "", ""): hideTweetStats* {.dbType: "STRING", parseIt: parseBool(it.s), formatIt: $it.}: bool hideBanner* {.dbType: "STRING", parseIt: parseBool(it.s), formatIt: $it.}: bool stickyProfile* {.dbType: "STRING", parseIt: parseBool(it.s), formatIt: $it.}: bool + replaceYouTube*: string + replaceTwitter*: string type QueryKind* = enum diff --git a/src/views/profile.nim b/src/views/profile.nim index 513b0d9..e6b8318 100644 --- a/src/views/profile.nim +++ b/src/views/profile.nim @@ -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,7 +23,7 @@ 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"): @@ -76,7 +76,7 @@ proc renderProfile*(profile: Profile; timeline: Timeline; let sticky = if prefs.stickyProfile: "sticky" else: "unset" tdiv(class="profile-tab", style={position: sticky}): - renderProfileCard(profile) + renderProfileCard(profile, prefs) if photoRail.len > 0: renderPhotoRail(profile, photoRail) diff --git a/src/views/tweet.nim b/src/views/tweet.nim index 407dd4e..d36ac84 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -92,7 +92,7 @@ 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"): @@ -103,7 +103,7 @@ proc renderCard(card: Card; prefs: Prefs): VNode = 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: @@ -147,7 +147,7 @@ proc renderQuoteMedia(quote: Quote): VNode = tdiv(class="quote-sensitive"): 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"): @@ -167,7 +167,7 @@ proc renderQuote(quote: Quote): VNode = renderReply(quote) tdiv(class="quote-text"): - verbatim linkifyText(quote.text) + verbatim linkifyText(quote.text, prefs) if quote.hasThread: a(class="show-thread", href=getLink(quote)): @@ -194,10 +194,10 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; class=""; 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(), prefs) From 11887b793acb7c3dc3ee16a292d8e535d4ba201d Mon Sep 17 00:00:00 2001 From: Zed Date: Thu, 15 Aug 2019 18:25:47 +0200 Subject: [PATCH 11/25] Improve cookie security --- nitter.nimble | 4 ++-- src/nitter.nim | 2 +- src/prefs.nim | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/nitter.nimble b/nitter.nimble index 039b7d0..3b984f6 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.11" +requires "jester >= 0.4.3" requires "regex >= 0.11.2" requires "q >= 0.0.7" requires "nimcrypto >= 0.3.9" diff --git a/src/nitter.nim b/src/nitter.nim index 45198bf..269f560 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -92,7 +92,7 @@ routes: post "/saveprefs": var prefs = getCookiePrefs(request) genUpdatePrefs() - setCookie("preferences", $prefs.id, daysForward(360)) + setCookie("preferences", $prefs.id, daysForward(360), httpOnly=true, secure=true) redirect("/settings") get "/settings": diff --git a/src/prefs.nim b/src/prefs.nim index 4fb9ef7..8451fc8 100644 --- a/src/prefs.nim +++ b/src/prefs.nim @@ -46,7 +46,7 @@ const prefList*: Table[string, seq[Pref]] = { "Display": @[ Pref(kind: checkbox, name: "hideTweetStats", - label: "Hide tweet stats (replies, retweets, likes", + label: "Hide tweet stats (replies, retweets, likes)", defaultState: false), Pref(kind: checkbox, name: "hideBanner", label: "Hide profile banner", From f27bc63d9d3de8d919a5a81e01e9a022de89cdf2 Mon Sep 17 00:00:00 2001 From: Zed Date: Thu, 15 Aug 2019 18:41:35 +0200 Subject: [PATCH 12/25] Add compile-time variable for default hostname --- src/prefs.nim | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/prefs.nim b/src/prefs.nim index 8451fc8..d73977b 100644 --- a/src/prefs.nim +++ b/src/prefs.nim @@ -1,6 +1,8 @@ import asyncdispatch, times, macros, tables, xmltree import types +const hostname {.strdefine.} = "nitter.net" + withCustomDb("prefs.db", "", "", ""): try: createTables() @@ -24,11 +26,12 @@ type 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: "nitter.net", placeholder: "Nitter hostname"), + defaultInput: hostname, placeholder: "Nitter hostname"), Pref(kind: input, name: "replaceYouTube", label: "Replace YouTube links with Invidious (blank to disable)", From fdd71946da62877f00d605f8967a05ae2a8f663c Mon Sep 17 00:00:00 2001 From: Zed Date: Thu, 15 Aug 2019 18:45:56 +0200 Subject: [PATCH 13/25] Fix empty link replacement --- src/formatters.nim | 5 +++-- src/prefs.nim | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/formatters.nim b/src/formatters.nim index 4a268c0..920301c 100644 --- a/src/formatters.nim +++ b/src/formatters.nim @@ -63,10 +63,11 @@ proc linkifyText*(text: string; prefs: Prefs): string = result = result.replace(twRegex, prefs.replaceTwitter) proc replaceUrl*(url: string; prefs: Prefs): string = + result = url if prefs.replaceYouTube.len > 0: - return url.replace(ytRegex, prefs.replaceYouTube) + result = url.replace(ytRegex, prefs.replaceYouTube) if prefs.replaceTwitter.len > 0: - return url.replace(twRegex, prefs.replaceTwitter) + result = url.replace(twRegex, prefs.replaceTwitter) proc stripTwitterUrls*(text: string): string = result = text diff --git a/src/prefs.nim b/src/prefs.nim index d73977b..f383860 100644 --- a/src/prefs.nim +++ b/src/prefs.nim @@ -44,7 +44,7 @@ const prefList*: Table[string, seq[Pref]] = { defaultState: false), Pref(kind: checkbox, name: "autoplayGifs", label: "Autoplay gifs", - defaultState: true), + defaultState: true) ], "Display": @[ From 9fc512d88d776b2c73f7286321c7a13cb6631e70 Mon Sep 17 00:00:00 2001 From: Zed Date: Thu, 15 Aug 2019 19:13:54 +0200 Subject: [PATCH 14/25] Add button to reset preferences --- public/css/style.css | 13 +++++++++---- src/nitter.nim | 6 ++++++ src/prefs.nim | 6 ++++++ src/views/preferences.nim | 8 ++++++-- 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index a633117..0963712 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1092,10 +1092,6 @@ legend { margin: .2em; 0; } -.pref-submit:hover { - background-color: #a8a8a8; -} - .pref-submit { background-color: #e2e2e2; color: #000; @@ -1106,6 +1102,15 @@ legend { margin-top: 4px; } +.pref-submit:hover { + background-color: #a8a8a8; +} + +.pref-reset { + float: right; + margin-top: -25px; +} + .icon-container { display: inline; } diff --git a/src/nitter.nim b/src/nitter.nim index 269f560..36a7448 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -95,6 +95,12 @@ routes: setCookie("preferences", $prefs.id, daysForward(360), httpOnly=true, secure=true) redirect("/settings") + post "/resetprefs": + var prefs = getCookiePrefs(request) + resetPrefs(prefs) + setCookie("preferences", $prefs.id, daysForward(360), httpOnly=true, secure=true) + redirect("/settings") + get "/settings": let prefs = getCookiePrefs(request) resp renderMain(renderPreferences(prefs), prefs, title=cfg.title, titleText="Preferences") diff --git a/src/prefs.nim b/src/prefs.nim index f383860..825d407 100644 --- a/src/prefs.nim +++ b/src/prefs.nim @@ -96,6 +96,12 @@ proc getPrefs*(id: string): Prefs = result = genDefaultPrefs() cache(result) +proc resetPrefs*(prefs: var Prefs) = + var defPrefs = genDefaultPrefs() + defPrefs.id = prefs.id + cache(defPrefs) + prefs = defPrefs + macro genUpdatePrefs*(): untyped = result = nnkStmtList.newTree() diff --git a/src/views/preferences.nim b/src/views/preferences.nim index d9cc199..f041e8e 100644 --- a/src/views/preferences.nim +++ b/src/views/preferences.nim @@ -53,9 +53,13 @@ macro renderPrefs*(): untyped = proc renderPreferences*(prefs: Prefs): VNode = buildHtml(tdiv(class="preferences-container")): - form(class="preferences", `method`="post", action="saveprefs"): - fieldset: + fieldset(class="preferences"): + form(`method`="post", action="saveprefs"): renderPrefs() button(`type`="submit", class="pref-submit"): text "Save preferences" + + form(`method`="post", action="resetprefs", class="pref-reset"): + button(`type`="submit", class="pref-submit"): + text "Reset preferences" From 6c365b8fba7cf8423a81e495a73c3b7c52ea0797 Mon Sep 17 00:00:00 2001 From: Zed Date: Thu, 15 Aug 2019 19:19:21 +0200 Subject: [PATCH 15/25] Include 'www.' in twitter/youtube link replacement --- src/formatters.nim | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/formatters.nim b/src/formatters.nim index 920301c..6c68425 100644 --- a/src/formatters.nim +++ b/src/formatters.nim @@ -11,8 +11,8 @@ const usernameRegex = re"(^|[^A-z0-9_?])@([A-z0-9_]+)" picRegex = re"pic.twitter.com/[^ ]+" ellipsisRegex = re" ?โ€ฆ" - ytRegex = re"youtu(be.com|.be)" - twRegex = re"twitter.com" + ytRegex = re"(www.)?youtu(be.com|.be)" + twRegex = re"(www.)?twitter.com" nbsp = $Rune(0x000A0) proc stripText*(text: string): string = @@ -65,9 +65,9 @@ proc linkifyText*(text: string; prefs: Prefs): string = proc replaceUrl*(url: string; prefs: Prefs): string = result = url if prefs.replaceYouTube.len > 0: - result = url.replace(ytRegex, prefs.replaceYouTube) + result = result.replace(ytRegex, prefs.replaceYouTube) if prefs.replaceTwitter.len > 0: - result = url.replace(twRegex, prefs.replaceTwitter) + result = result.replace(twRegex, prefs.replaceTwitter) proc stripTwitterUrls*(text: string): string = result = text From 13b75a69544aa16b8483ddbbcade34a48b7b4709 Mon Sep 17 00:00:00 2001 From: Zed Date: Thu, 15 Aug 2019 22:44:11 +0200 Subject: [PATCH 16/25] Style preferences page --- public/css/style.css | 124 +++++++++++++++++++++++++++++++------- src/views/preferences.nim | 20 +++--- 2 files changed, 113 insertions(+), 31 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index 0963712..77d6844 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -7,6 +7,10 @@ body { line-height: 1.3; } +* { + outline: unset; +} + #posts { background-color: #161616; } @@ -200,13 +204,16 @@ nav { justify-content: flex-end; } +.site-name { + font-weight: 600; +} + .site-logo { width: 35px; height: 35px; } .item.right a { - font-size: 16px; padding-left: 4px; } @@ -1072,6 +1079,41 @@ video, .video-container img { .preferences { background-color: #222222; + width: 100%; +} + +.preferences input[type="text"] { + max-width: 110px; + 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; +} + +.preferences button { + background-color: #121212; + color: #f8f8f2; + border: 1px solid #ff6c6091; + padding: 3px 6px; + margin-top: 4px; + font-size: 14px; +} + +.preferences button:hover { + border-color: #ff6c60; +} + +.preferences button:active { + border-color: #ff9f97; } fieldset { @@ -1081,36 +1123,76 @@ fieldset { legend { width: 100%; - padding: .6em 0 .3em 0; - margin-bottom: .2em; + padding: .2em 0 .3em 0; + margin: 0; border: 0; - border-bottom: 1px solid #888888; font-size: 16px; + border-bottom: 1px solid #888888; + margin-bottom: 5px; } -.pref-group { - margin: .2em; 0; -} - -.pref-submit { - background-color: #e2e2e2; - color: #000; - border: none; - border-radius: 2px; - padding: 3px 6px; - margin-left: 6px; - margin-top: 4px; -} - -.pref-submit:hover { - background-color: #a8a8a8; +.pref-input { + position: relative; + margin-bottom: 5px; } .pref-reset { float: right; - margin-top: -25px; + margin-top: -28px; } .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/src/views/preferences.nim b/src/views/preferences.nim index f041e8e..9023440 100644 --- a/src/views/preferences.nim +++ b/src/views/preferences.nim @@ -5,28 +5,28 @@ import ../types, ../prefs proc genCheckbox(pref, label: string; state: bool): VNode = buildHtml(tdiv(class="pref-group")): - if state: - input(name=pref, `type`="checkbox", checked="") - else: - input(name=pref, `type`="checkbox") - label(`for`=pref): text label + 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 - label(`for`=pref): text label proc genInput(pref, label, state, placeholder: string): VNode = let s = xmltree.escape(state) let p = xmltree.escape(placeholder) - buildHtml(tdiv(class="pref-group")): - verbatim &"" + buildHtml(tdiv(class="pref-group pref-input")): label(`for`=pref): text label + verbatim &"" macro renderPrefs*(): untyped = result = nnkCall.newTree( @@ -57,9 +57,9 @@ proc renderPreferences*(prefs: Prefs): VNode = form(`method`="post", action="saveprefs"): renderPrefs() - button(`type`="submit", class="pref-submit"): + button(`type`="submit"): text "Save preferences" form(`method`="post", action="resetprefs", class="pref-reset"): - button(`type`="submit", class="pref-submit"): + button(`type`="submit"): text "Reset preferences" From 6a7a65e16b9ec0f3d8c5695790f3f511c1b491b9 Mon Sep 17 00:00:00 2001 From: Zed Date: Thu, 15 Aug 2019 22:44:59 +0200 Subject: [PATCH 17/25] Update norm --- nitter.nimble | 2 +- src/types.nim | 22 +++++++++++----------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/nitter.nimble b/nitter.nimble index 3b984f6..30e43cf 100644 --- a/nitter.nimble +++ b/nitter.nimble @@ -11,7 +11,7 @@ bin = @["nitter"] # Dependencies requires "nim >= 0.19.9" -requires "norm <= 1.0.11" +requires "norm >= 1.0.13" requires "jester >= 0.4.3" requires "regex >= 0.11.2" requires "q >= 0.0.7" diff --git a/src/types.nim b/src/types.nim index 38ef3b5..2ad4f7b 100644 --- a/src/types.nim +++ b/src/types.nim @@ -22,17 +22,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() + formatIt: dbValue(it.toUnix()) .}: Time updated* {. dbType: "INTEGER" parseIt: it.i.fromUnix() - formatIt: getTime().toUnix() + formatIt: dbValue(getTime().toUnix()) .}: Time Video* = object @@ -42,19 +42,19 @@ db("cache.db", "", "", ""): url*: string thumb*: string views*: string + available*: bool playbackType* {. dbType: "STRING" parseIt: parseEnum[VideoType](it.s) - formatIt: $it + formatIt: dbValue($it) .}: VideoType - available* {.dbType: "STRING", parseIt: parseBool(it.s) formatIt: $it.}: bool Prefs* = object - videoPlayback* {.dbType: "STRING", parseIt: parseBool(it.s), formatIt: $it.}: bool - autoplayGifs* {.dbType: "STRING", parseIt: parseBool(it.s), formatIt: $it.}: bool - hideTweetStats* {.dbType: "STRING", parseIt: parseBool(it.s), formatIt: $it.}: bool - hideBanner* {.dbType: "STRING", parseIt: parseBool(it.s), formatIt: $it.}: bool - stickyProfile* {.dbType: "STRING", parseIt: parseBool(it.s), formatIt: $it.}: bool + videoPlayback*: bool + autoplayGifs*: bool + hideTweetStats*: bool + hideBanner*: bool + stickyProfile*: bool replaceYouTube*: string replaceTwitter*: string From 1464131707ea7ff497818f0f2f97511a0fee031e Mon Sep 17 00:00:00 2001 From: Zed Date: Thu, 15 Aug 2019 23:17:13 +0200 Subject: [PATCH 18/25] Clean up --- src/nitter.nim | 61 ++++++++++++++++++------------------------- src/views/general.nim | 6 ++--- tests/test_card.py | 38 +++++++++++++-------------- 3 files changed, 48 insertions(+), 57 deletions(-) diff --git a/src/nitter.nim b/src/nitter.nim index 36a7448..a985371 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -37,8 +37,7 @@ proc showSingleTimeline(name, after, agent: string; query: Option[Query]; return "" let profileHtml = renderProfile(profile, timeline, await railFut, prefs) - return renderMain(profileHtml, prefs, title=cfg.title, titleText=pageTitle(profile), - desc=pageDesc(profile)) + return renderMain(profileHtml, cfg.title, pageTitle(profile), pageDesc(profile)) proc showMultiTimeline(names: seq[string]; after, agent: string; query: Option[Query]; prefs: Prefs): Future[string] {.async.} = @@ -51,7 +50,7 @@ proc showMultiTimeline(names: seq[string]; after, agent: string; query: Option[Q var timeline = renderMulti(await getTimelineSearch(get(q), after, agent), names.join(","), prefs) - return renderMain(timeline, prefs, title=cfg.title, titleText="Multi") + return renderMain(timeline, cfg.title, "Multi") proc showTimeline(name, after: string; query: Option[Query]; prefs: Prefs): Future[string] {.async.} = @@ -65,10 +64,10 @@ proc showTimeline(name, after: string; query: Option[Query]; template respTimeline(timeline: typed) = if timeline.len == 0: - resp Http404, showError("User \"" & @"name" & "\" not found", cfg.title, prefs) + resp Http404, showError("User \"" & @"name" & "\" not found", cfg.title) resp timeline -proc getCookiePrefs(request: Request): Prefs = +template cookiePrefs(): untyped {.dirty.} = getPrefs(request.cookies.getOrDefault("preferences")) setProfileCacheTime(cfg.profileCacheTime) @@ -80,59 +79,55 @@ settings: routes: get "/": - let prefs = getCookiePrefs(request) - resp renderMain(renderSearch(), prefs, title=cfg.title) + resp renderMain(renderSearch(), cfg.title) post "/search": if @"query".len == 0: - resp Http404, showError("Please enter a username.", cfg.title, - getCookiePrefs(request)) + resp Http404, showError("Please enter a username.", cfg.title) redirect("/" & @"query") post "/saveprefs": - var prefs = getCookiePrefs(request) + var prefs = cookiePrefs() genUpdatePrefs() setCookie("preferences", $prefs.id, daysForward(360), httpOnly=true, secure=true) - redirect("/settings") + redirect("/") post "/resetprefs": - var prefs = getCookiePrefs(request) + var prefs = cookiePrefs() resetPrefs(prefs) setCookie("preferences", $prefs.id, daysForward(360), httpOnly=true, secure=true) redirect("/settings") get "/settings": - let prefs = getCookiePrefs(request) - resp renderMain(renderPreferences(prefs), prefs, title=cfg.title, titleText="Preferences") + resp renderMain(renderPreferences(cookiePrefs()), cfg.title, "Preferences") get "/@name/?": cond '.' notin @"name" - let prefs = getCookiePrefs(request) - respTimeline(await showTimeline(@"name", @"after", none(Query), prefs)) + respTimeline(await showTimeline(@"name", @"after", none(Query), cookiePrefs())) get "/@name/search": cond '.' notin @"name" - let prefs = getCookiePrefs(request) + let prefs = cookiePrefs() let query = initQuery(@"filter", @"include", @"not", @"sep", @"name") - respTimeline(await showTimeline(@"name", @"after", some(query), prefs)) + respTimeline(await showTimeline(@"name", @"after", some(query), cookiePrefs())) get "/@name/replies": cond '.' notin @"name" - let prefs = getCookiePrefs(request) - respTimeline(await showTimeline(@"name", @"after", some(getReplyQuery(@"name")), prefs)) + let prefs = cookiePrefs() + respTimeline(await showTimeline(@"name", @"after", some(getReplyQuery(@"name")), cookiePrefs())) get "/@name/media": cond '.' notin @"name" - let prefs = getCookiePrefs(request) - respTimeline(await showTimeline(@"name", @"after", some(getMediaQuery(@"name")), prefs)) + let prefs = cookiePrefs() + respTimeline(await showTimeline(@"name", @"after", some(getMediaQuery(@"name")), cookiePrefs())) get "/@name/status/@id": cond '.' notin @"name" - let prefs = getCookiePrefs(request) + let prefs = cookiePrefs() let conversation = await getTweet(@"name", @"id", getAgent()) if conversation == nil or conversation.tweet.id.len == 0: - resp Http404, showError("Tweet not found", cfg.title, prefs) + resp Http404, showError("Tweet not found", cfg.title) let title = pageTitle(conversation.tweet.profile) let desc = conversation.tweet.text @@ -141,29 +136,26 @@ routes: if conversation.tweet.video.isSome(): let thumb = get(conversation.tweet.video).thumb let vidUrl = getVideoEmbed(conversation.tweet.id) - resp renderMain(html, prefs, 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, prefs, 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, prefs, 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 prefs = getCookiePrefs(request) - let uri = parseUri(decodeUrl(@"url")) path = uri.path.split("/")[2 .. ^1].join("/") filename = cfg.cacheDir / cleanFilename(path & uri.query) if getHmac($uri) != @"sig": - resp showError("Failed to verify signature", cfg.title, prefs) + resp showError("Failed to verify signature", cfg.title) if not existsDir(cfg.cacheDir): createDir(cfg.cacheDir) @@ -185,11 +177,10 @@ routes: get "/video/@sig/@url": cond "http" in @"url" cond "video.twimg" in @"url" - let prefs = getCookiePrefs(request) let url = decodeUrl(@"url") if getHmac(url) != @"sig": - resp showError("Failed to verify signature", cfg.title, prefs) + resp showError("Failed to verify signature", cfg.title) let client = newAsyncHttpClient() let video = await client.getContent(url) diff --git a/src/views/general.nim b/src/views/general.nim index 2ca43d6..8639053 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -17,7 +17,7 @@ proc renderNavbar*(title: string): VNode = icon "info-circled", title="About", href="/about" icon "cog-2", title="Preferences", href="/settings" -proc renderMain*(body: VNode; prefs: Prefs; title="Nitter"; titleText=""; desc=""; +proc renderMain*(body: VNode; title="Nitter"; titleText=""; desc=""; `type`="article"; video=""; images: seq[string] = @[]): string = let node = buildHtml(html(lang="en")): head: @@ -62,5 +62,5 @@ proc renderError*(error: string): VNode = tdiv(class="error-panel"): span: text error -proc showError*(error: string; title: string; prefs: Prefs): string = - renderMain(renderError(error), prefs, title=title, titleText="Error") +proc showError*(error, title: string): string = + renderMain(renderError(error), title, "Error") diff --git a/tests/test_card.py b/tests/test_card.py index ac7b545..fba1041 100644 --- a/tests/test_card.py +++ b/tests/test_card.py @@ -6,62 +6,62 @@ 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', '1161324818224078848', 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', '1161172194040246272', 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', '161236662619389953', 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', '1160647657574076423', 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 = [ @@ -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', '1161613174514290688'], + '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', '1161379576087568386'], + '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', '161048988763795457'] + '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) From d05f4fd3ee6c13ea402a680867c6dc70872322bf Mon Sep 17 00:00:00 2001 From: Zed Date: Fri, 16 Aug 2019 01:57:36 +0200 Subject: [PATCH 19/25] Improve prefs page css --- public/css/style.css | 58 ++++++++++++++++++++++----------------- src/views/preferences.nim | 2 +- 2 files changed, 34 insertions(+), 26 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index 77d6844..b8caadc 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -208,6 +208,11 @@ nav { font-weight: 600; } +.site-name:hover { + color: #ffaca0; + text-decoration: unset; +} + .site-logo { width: 35px; height: 35px; @@ -1075,15 +1080,17 @@ video, .video-container img { max-width: 600px; margin: 0 auto; width: 100%; + margin-top: 10px; } .preferences { - background-color: #222222; + background-color: #1f1f1f; width: 100%; + padding: 5px 15px 15px 15px; } .preferences input[type="text"] { - max-width: 110px; + max-width: 120px; background-color: #121212; padding: 1px 4px; color: #f8f8f2; @@ -1099,23 +1106,6 @@ video, .video-container img { border-color: #ff6c60; } -.preferences button { - background-color: #121212; - color: #f8f8f2; - border: 1px solid #ff6c6091; - padding: 3px 6px; - margin-top: 4px; - font-size: 14px; -} - -.preferences button:hover { - border-color: #ff6c60; -} - -.preferences button:active { - border-color: #ff9f97; -} - fieldset { margin: .35em 0 .75em; border: 0; @@ -1123,22 +1113,40 @@ fieldset { legend { width: 100%; - padding: .2em 0 .3em 0; + padding: .6em 0 .3em 0; margin: 0; border: 0; font-size: 16px; - border-bottom: 1px solid #888888; - margin-bottom: 5px; + border-bottom: 1px solid #3e3e35; + margin-bottom: 8px; } .pref-input { position: relative; - margin-bottom: 5px; + 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: right; - margin-top: -28px; + float: left; } .icon-container { diff --git a/src/views/preferences.nim b/src/views/preferences.nim index 9023440..30c79d7 100644 --- a/src/views/preferences.nim +++ b/src/views/preferences.nim @@ -57,7 +57,7 @@ proc renderPreferences*(prefs: Prefs): VNode = form(`method`="post", action="saveprefs"): renderPrefs() - button(`type`="submit"): + button(`type`="submit", class="pref-submit"): text "Save preferences" form(`method`="post", action="resetprefs", class="pref-reset"): From 16f77223ff95f7af804caa089554e5a85d915adc Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 17 Aug 2019 21:49:41 +0200 Subject: [PATCH 20/25] Generate Prefs type --- src/prefs.nim | 95 ++------------------------------ src/prefs_impl.nim | 110 ++++++++++++++++++++++++++++++++++++++ src/types.nim | 10 +--- src/views/preferences.nim | 2 +- 4 files changed, 116 insertions(+), 101 deletions(-) create mode 100644 src/prefs_impl.nim diff --git a/src/prefs.nim b/src/prefs.nim index 825d407..8ae56ee 100644 --- a/src/prefs.nim +++ b/src/prefs.nim @@ -1,7 +1,8 @@ -import asyncdispatch, times, macros, tables, xmltree +import strutils import types +import prefs_impl -const hostname {.strdefine.} = "nitter.net" +export genUpdatePrefs withCustomDb("prefs.db", "", "", ""): try: @@ -9,74 +10,6 @@ withCustomDb("prefs.db", "", "", ""): except DbError: discard -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: "videoPlayback", - label: "Enable hls.js video playback (requires JavaScript)", - 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(): - result.add nnkExprColonExpr.newTree( - ident(pref.name), - case pref.kind - of checkbox: newLit(pref.defaultState) - of select: newLit(pref.defaultOption) - of input: newLit(pref.defaultInput)) - proc cache*(prefs: var Prefs) = withCustomDb("prefs.db", "", "", ""): try: @@ -101,25 +34,3 @@ proc resetPrefs*(prefs: var Prefs) = defPrefs.id = prefs.id cache(defPrefs) prefs = defPrefs - -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/prefs_impl.nim b/src/prefs_impl.nim new file mode 100644 index 0000000..53c37ee --- /dev/null +++ b/src/prefs_impl.nim @@ -0,0 +1,110 @@ +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: "videoPlayback", + label: "Enable hls.js video playback (requires JavaScript)", + 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) + +macro genPrefsType*(): untyped = + result = nnkTypeSection.newTree(nnkTypeDef.newTree( + nnkPostfix.newTree(ident("*"), ident("Prefs")), newEmptyNode(), + nnkObjectTy.newTree(newEmptyNode(), newEmptyNode(), nnkRecList.newTree()))) + + result[0][2][2].add nnkIdentDefs.newTree( + nnkPostfix.newTree(ident("*"), ident("id")), ident("int"), newEmptyNode()) + + for pref in allPrefs(): + result[0][2][2].add nnkIdentDefs.newTree( + nnkPostfix.newTree(ident("*"), ident(pref.name)), + (case pref.kind + of checkbox: ident("bool") + of input, select: ident("string")), + newEmptyNode()) diff --git a/src/types.nim b/src/types.nim index 2ad4f7b..7256b9b 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 @@ -49,14 +50,7 @@ db("cache.db", "", "", ""): formatIt: dbValue($it) .}: VideoType - Prefs* = object - videoPlayback*: bool - autoplayGifs*: bool - hideTweetStats*: bool - hideBanner*: bool - stickyProfile*: bool - replaceYouTube*: string - replaceTwitter*: string + genPrefsType() type QueryKind* = enum diff --git a/src/views/preferences.nim b/src/views/preferences.nim index 30c79d7..70a0a5b 100644 --- a/src/views/preferences.nim +++ b/src/views/preferences.nim @@ -1,7 +1,7 @@ import tables, macros, strformat, xmltree import karax/[karaxdsl, vdom, vstyles] -import ../types, ../prefs +import ../types, ../prefs_impl proc genCheckbox(pref, label: string; state: bool): VNode = buildHtml(tdiv(class="pref-group")): From 520bafc617d160cb11b2c15259d723400d13cc2e Mon Sep 17 00:00:00 2001 From: Zed Date: Sun, 18 Aug 2019 01:26:38 +0200 Subject: [PATCH 21/25] Undo broken type gen, verify type at compile time --- src/prefs.nim | 13 ++++++++++++- src/prefs_impl.nim | 17 +---------------- src/types.nim | 9 ++++++++- 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/src/prefs.nim b/src/prefs.nim index 8ae56ee..e2aea5c 100644 --- a/src/prefs.nim +++ b/src/prefs.nim @@ -1,9 +1,20 @@ -import strutils +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() diff --git a/src/prefs_impl.nim b/src/prefs_impl.nim index 53c37ee..410ec95 100644 --- a/src/prefs_impl.nim +++ b/src/prefs_impl.nim @@ -54,7 +54,7 @@ const prefList*: Table[string, seq[Pref]] = { ] }.toTable -iterator allPrefs(): Pref = +iterator allPrefs*(): Pref = for k, v in prefList: for pref in v: yield pref @@ -93,18 +93,3 @@ macro genUpdatePrefs*(): untyped = result.add quote do: cache(prefs) -macro genPrefsType*(): untyped = - result = nnkTypeSection.newTree(nnkTypeDef.newTree( - nnkPostfix.newTree(ident("*"), ident("Prefs")), newEmptyNode(), - nnkObjectTy.newTree(newEmptyNode(), newEmptyNode(), nnkRecList.newTree()))) - - result[0][2][2].add nnkIdentDefs.newTree( - nnkPostfix.newTree(ident("*"), ident("id")), ident("int"), newEmptyNode()) - - for pref in allPrefs(): - result[0][2][2].add nnkIdentDefs.newTree( - nnkPostfix.newTree(ident("*"), ident(pref.name)), - (case pref.kind - of checkbox: ident("bool") - of input, select: ident("string")), - newEmptyNode()) diff --git a/src/types.nim b/src/types.nim index 7256b9b..e254c4e 100644 --- a/src/types.nim +++ b/src/types.nim @@ -50,7 +50,14 @@ db("cache.db", "", "", ""): formatIt: dbValue($it) .}: VideoType - genPrefsType() + Prefs* = object + videoPlayback*: bool + autoplayGifs*: bool + hideTweetStats*: bool + hideBanner*: bool + stickyProfile*: bool + replaceYouTube*: string + replaceTwitter*: string type QueryKind* = enum From 5f2127fb5222ed5e987cd40de5d5a03f4e59eef0 Mon Sep 17 00:00:00 2001 From: Zed Date: Sun, 18 Aug 2019 21:54:37 +0200 Subject: [PATCH 22/25] Fix site logo size --- public/css/style.css | 1 + 1 file changed, 1 insertion(+) diff --git a/public/css/style.css b/public/css/style.css index b8caadc..a523a4a 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -214,6 +214,7 @@ nav { } .site-logo { + display: block; width: 35px; height: 35px; } From c2413ccfdde857074dbe20b2c5f8eb66b1f8f42d Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 19 Aug 2019 03:02:34 +0200 Subject: [PATCH 23/25] Set cookie security using useHttps config option --- src/config.nim | 1 + src/nitter.nim | 4 ++-- src/types.nim | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/config.nim b/src/config.nim index 7c2bbdf..2b2317f 100644 --- a/src/config.nim +++ b/src/config.nim @@ -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/nitter.nim b/src/nitter.nim index a985371..9a66357 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -89,13 +89,13 @@ routes: post "/saveprefs": var prefs = cookiePrefs() genUpdatePrefs() - setCookie("preferences", $prefs.id, daysForward(360), httpOnly=true, secure=true) + setCookie("preferences", $prefs.id, daysForward(360), httpOnly=true, secure=cfg.useHttps) redirect("/") post "/resetprefs": var prefs = cookiePrefs() resetPrefs(prefs) - setCookie("preferences", $prefs.id, daysForward(360), httpOnly=true, secure=true) + setCookie("preferences", $prefs.id, daysForward(360), httpOnly=true, secure=cfg.useHttps) redirect("/settings") get "/settings": diff --git a/src/types.nim b/src/types.nim index e254c4e..247a65c 100644 --- a/src/types.nim +++ b/src/types.nim @@ -167,6 +167,7 @@ type Config* = ref object address*: string port*: int + useHttps*: bool title*: string staticDir*: string cacheDir*: string From ed327bac242146fd347fec9c836703315fc43bac Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 19 Aug 2019 03:28:04 +0200 Subject: [PATCH 24/25] Add video playback preferences --- nitter.conf | 1 + src/prefs_impl.nim | 12 ++++++++++-- src/types.nim | 4 +++- src/views/tweet.nim | 40 +++++++++++++++++++++++++++++----------- 4 files changed, 43 insertions(+), 14 deletions(-) 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/src/prefs_impl.nim b/src/prefs_impl.nim index 410ec95..eda6cb2 100644 --- a/src/prefs_impl.nim +++ b/src/prefs_impl.nim @@ -32,8 +32,16 @@ const prefList*: Table[string, seq[Pref]] = { ], "Media": @[ - Pref(kind: checkbox, name: "videoPlayback", - label: "Enable hls.js video playback (requires JavaScript)", + 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", diff --git a/src/types.nim b/src/types.nim index 247a65c..611b361 100644 --- a/src/types.nim +++ b/src/types.nim @@ -51,7 +51,9 @@ db("cache.db", "", "", ""): .}: VideoType Prefs* = object - videoPlayback*: bool + hlsPlayback*: bool + mp4Playback*: bool + muteVideos*: bool autoplayGifs*: bool hideTweetStats*: bool hideBanner*: bool diff --git a/src/views/tweet.nim b/src/views/tweet.nim index d36ac84..4800ee2 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -45,24 +45,42 @@ proc renderAlbum(tweet: Tweet): VNode = target="_blank", style={display: flex}): genImg(photo) +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"): - let thumb = video.thumb.getSigUrl("pic") - case video.playbackType - of mp4: - video(poster=thumb, controls=""): - source(src=video.url.getSigUrl("video"), `type`="video/mp4") - of m3u8, vmap: - if prefs.videoPlayback: + 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: - img(src=thumb) - tdiv(class="video-overlay"): - p: text "Video playback disabled" + else: + renderVideoDisabled(video) proc renderGif(gif: Gif; prefs: Prefs): VNode = buildHtml(tdiv(class="attachments media-gif")): From 3f7ccb55250170f5341ef067c4f21a334b639470 Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 19 Aug 2019 04:37:28 +0200 Subject: [PATCH 25/25] Go back to previous page when saving settings --- src/nitter.nim | 9 +++++++-- src/views/preferences.nim | 4 +++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/nitter.nim b/src/nitter.nim index 9a66357..0d737d0 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -90,7 +90,7 @@ routes: var prefs = cookiePrefs() genUpdatePrefs() setCookie("preferences", $prefs.id, daysForward(360), httpOnly=true, secure=cfg.useHttps) - redirect("/") + redirect(decodeUrl(@"referer")) post "/resetprefs": var prefs = cookiePrefs() @@ -99,7 +99,12 @@ routes: redirect("/settings") get "/settings": - resp renderMain(renderPreferences(cookiePrefs()), cfg.title, "Preferences") + 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" diff --git a/src/views/preferences.nim b/src/views/preferences.nim index 70a0a5b..47d88ee 100644 --- a/src/views/preferences.nim +++ b/src/views/preferences.nim @@ -51,10 +51,12 @@ macro renderPrefs*(): untyped = result[2].add stmt -proc renderPreferences*(prefs: Prefs): VNode = +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"):