浏览代码

Add client preferences

Zed 7 年之前
父节点
当前提交
966b3d5d62
共有 12 个文件被更改,包括 330 次插入83 次删除
  1. 43 1
      public/style.css
  2. 4 4
      src/cache.nim
  3. 1 1
      src/config.nim
  4. 53 25
      src/nitter.nim
  5. 95 0
      src/prefs.nim
  6. 12 12
      src/types.nim
  7. 5 5
      src/views/general.nim
  8. 69 0
      src/views/preferences.nim
  9. 4 4
      src/views/profile.nim
  10. 7 7
      src/views/status.nim
  11. 8 8
      src/views/timeline.nim
  12. 29 16
      src/views/tweet.nim

+ 43 - 1
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;
+}

+ 4 - 4
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

+ 1 - 1
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)

+ 53 - 25
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()

+ 95 - 0
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)

+ 12 - 12
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"

+ 5 - 5
src/views/general.nim

@@ -1,6 +1,6 @@
 import karax/[karaxdsl, vdom]
 
-import ../utils
+import ../utils, ../types
 
 const doctype = "<!DOCTYPE html>\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")

+ 69 - 0
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"

+ 4 - 4
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)

+ 7 - 7
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)

+ 8 - 8
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:

+ 29 - 16
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"
-
-proc renderGif(gif: Gif): VNode =
+          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; 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())