Ver Fonte

Use Karax html rendering instead of source filters (#2)

* Use Karax html rendering instead of source filters
Zed há 7 anos atrás
pai
commit
ab36664ad2

+ 1 - 0
nitter.nimble

@@ -16,3 +16,4 @@ requires "jester >= 0.4.1"
 requires "regex >= 0.11.2"
 requires "q >= 0.0.7"
 requires "nimcrypto >= 0.3.9"
+requires "karax#b99a543"

+ 28 - 18
public/style.css

@@ -63,20 +63,20 @@ a:hover {
     margin-left: 58px;
 }
 
-.media-heading {
+.tweet-header {
     padding: 0;
     vertical-align: bottom;
     flex-basis: 100%;
     margin-bottom: .2em;
 }
 
-.media-heading a {
+.tweet-header a {
     display: inline-block;
     word-break: break-all;
     max-width: 100%;
 }
 
-.heading-name-row {
+.tweet-name-row {
     padding: 0;
     display: flex;
     justify-content: space-between;
@@ -132,7 +132,7 @@ a:hover {
     font-weight: bold;
 }
 
-.heading-right {
+.tweet-date {
     display: flex;
     flex-shrink: 0;
     margin-left: 4px;
@@ -247,7 +247,7 @@ nav {
     overflow: hidden;
 }
 
-.gallery-row .image-attachment, .attachments .image-attachment {
+.gallery-row .still-image, .attachments .image-attachment {
     width: 100%;
 }
 
@@ -348,6 +348,7 @@ video {
 }
 
 .show-more {
+    background-color: #161616;
     text-align: center;
     padding: .75em 0;
     display: block;
@@ -508,7 +509,7 @@ video {
     margin-top: 5px;
 }
 
-.photo-rail-heading {
+.photo-rail-header {
     padding: 5px 12px 0px 12px;
 }
 
@@ -627,8 +628,8 @@ video {
 }
 
 .thread-line .unavailable::before {
-    top: 40px;
-    margin-bottom: 19px;
+    top: 48px;
+    margin-bottom: 28px;
 }
 
 .thread-last .status-el::before {
@@ -641,7 +642,7 @@ video {
 .thread-line .more-replies::before {
     content: '...';
     background: unset;
-    color: #b94e46;
+    color: #ad433b;
     font-weight: bold;
     font-size: 22px;
     line-height: 0.25em;
@@ -750,18 +751,26 @@ video {
 }
 
 .timeline-footer, .timeline-header {
-    max-width: 550px;
-    margin: 0 auto;
+    background-color: #161616;
     padding: 6px 0px;
 }
 
-.timeline-none, .timeline-protected {
+.timeline-protected {
+    padding-left: 12px;
+}
+
+.timeline-protected p {
+    margin: 8px 0px;
+}
+
+.timeline-none, .timeline-protected h2 {
     color: #ff6c60;
     font-size: 21px;
     font-weight: 600;
 }
 
 .timeline-end {
+    background-color: #161616;
     text-align: center;
     font-size: 16px;
     color: #ff6c60;
@@ -771,14 +780,14 @@ video {
 .unavailable-box {
     width: 100%;
     height: 100%;
-    padding: 8px;
+    padding: 12px;
     border: solid 1px #404040;
     border-radius: 10px;
     background-color: #121212;
 }
 
 .unavailable-quote {
-    padding: 8px;
+    padding: 6px;
 }
 
 .quote {
@@ -786,16 +795,17 @@ video {
     border: solid 1px #404040;
     border-radius: 10px;
     background-color: #121212;
+    overflow: auto;
+    padding: 6px;
+    position: relative;
 }
 
 .quote:hover {
     border-color: #808080;
 }
 
-.quote-container {
-    position: relative;
-    overflow: auto;
-    padding: 6px;
+.quote.unavailable:hover {
+    border-color: #404040;
 }
 
 .quote-link {

+ 5 - 4
src/api.nim

@@ -238,14 +238,15 @@ proc getTweet*(username, id: string): Future[Conversation] {.async.} =
   let pollFut = getConversationPolls(result)
   await all(vidsFut, pollFut)
 
-proc finishTimeline(json: JsonNode; query: Option[Query]): Future[Timeline] {.async.} =
+proc finishTimeline(json: JsonNode; query: Option[Query]; after: string): Future[Timeline] {.async.} =
   if json == nil: return Timeline()
 
   result = Timeline(
     hasMore: json["has_more_items"].to(bool),
     maxId: json.getOrDefault("max_position").getStr(""),
     minId: json.getOrDefault("min_position").getStr("").cleanPos(),
-    query: query
+    query: query,
+    beginning: after.len == 0
   )
 
   if json["new_latent_count"].to(int) == 0: return
@@ -281,7 +282,7 @@ proc getTimeline*(username, after: string): Future[Timeline] {.async.} =
     params.add {"max_position": after}
 
   let json = await fetchJson(base / (timelineUrl % username) ? params, headers)
-  result = await finishTimeline(json, none(Query))
+  result = await finishTimeline(json, none(Query), after)
 
 proc getTimelineSearch*(username, after: string; query: Query): Future[Timeline] {.async.} =
   let queryParam = genQueryParam(query)
@@ -308,4 +309,4 @@ proc getTimelineSearch*(username, after: string; query: Query): Future[Timeline]
   }
 
   let json = await fetchJson(base / timelineSearchUrl ? params, headers)
-  result = await finishTimeline(json, some(query))
+  result = await finishTimeline(json, some(query), after)

+ 0 - 17
src/formatters.nim

@@ -70,23 +70,6 @@ proc getUserpic*(userpic: string; style=""): string =
 proc getUserpic*(profile: Profile; style=""): string =
   getUserPic(profile.userpic, style)
 
-proc genImg*(url: string; class=""): string =
-  result = img(src = url.getSigUrl("pic"), class = class, alt = "Image")
-
-proc linkUser*(profile: Profile; class=""): string =
-  let
-    username = "username" in class
-    href = &"/{profile.username}"
-    text = if username: "@" & profile.username
-           else: xmltree.escape(profile.fullname)
-
-  result = a(text, href = href, class = class, title = text)
-
-  if not username and profile.verified:
-    result &= span("✔", class="icon verified-icon", title="Verified account")
-  if not username and profile.protected:
-    result &= span("🔒", class="icon protected-icon", title="Protected account")
-
 proc pageTitle*(profile: Profile): string =
   &"{profile.fullname} (@{profile.username}) | Nitter"
 

+ 3 - 4
src/nitter.nim

@@ -3,8 +3,7 @@ import jester, regex
 
 import api, utils, types, cache, formatters, search
 
-include views/"user.nimf"
-include views/"general.nimf"
+import views/[general, profile, status]
 
 const cacheDir {.strdefine.} = "/tmp/nitter"
 
@@ -24,7 +23,7 @@ proc showTimeline(name, after: string; query: Option[Query]): Future[string] {.a
   if profile.username.len == 0:
     return ""
 
-  let profileHtml = renderProfile(profile, await timelineFut, await railFut, after.len == 0)
+  let profileHtml = renderProfile(profile, await timelineFut, await railFut)
   return renderMain(profileHtml, title=pageTitle(profile))
 
 template respTimeline(timeline: typed) =
@@ -34,7 +33,7 @@ template respTimeline(timeline: typed) =
 
 routes:
   get "/":
-    resp renderMain(renderSearchPanel(), title=pageTitle("Search"))
+    resp renderMain(renderSearch(), title=pageTitle("Search"))
 
   post "/search":
     if @"query".len == 0:

+ 1 - 1
src/parser.nim

@@ -176,5 +176,5 @@ proc parsePhotoRail*(node: XmlNode): seq[GalleryPhoto] =
     result.add GalleryPhoto(
       url: img.attr("data-image-url"),
       tweetId: img.attr("data-tweet-id"),
-      color: img.attr("background-color").replace("style", "background-color")
+      color: img.attr("background-color").replace("style: ", "")
     )

+ 2 - 1
src/parserutils.nim

@@ -91,9 +91,10 @@ proc getBanner*(tweet: XmlNode): string =
     result = url.replace("600x200", "1500x500")
   else:
     result = tweet.selectAttr(".ProfileCard-bg", "style")
+    result = result.replace("background-color: ", "")
 
   if result.len == 0:
-    result = "background-color: #161616"
+    result = "#161616"
 
 proc getPopupStats*(profile: var Profile; node: XmlNode) =
   for s in node.selectAll( ".ProfileCardStats-statLink"):

+ 0 - 9
src/search.nim

@@ -81,12 +81,3 @@ proc cleanPos*(pos: string): string =
 
 proc genPos*(pos: string): string =
   posPrefix & pos & posSuffix
-
-proc tabClass*(timeline: Timeline; tab: string): string =
-  result = '"' & "tab-item"
-  if timeline.query.isNone:
-    if tab == "tweets":
-      result &= " active"
-  elif $timeline.query.get().queryType == tab:
-    result &= " active"
-  result &= '"'

+ 1 - 0
src/types.nim

@@ -124,6 +124,7 @@ type
     minId*: string
     maxId*: string
     hasMore*: bool
+    beginning*: bool
     query*: Option[Query]
 
 proc contains*(thread: Thread; tweet: Tweet): bool =

+ 35 - 0
src/views/general.nim

@@ -0,0 +1,35 @@
+import karax/[karaxdsl, vdom]
+
+const doctype = "<!DOCTYPE html>\n"
+
+proc renderMain*(body: VNode; title="Nitter"): string =
+  let node = buildHtml(html(lang="en")):
+    head:
+      title: text title
+      link(rel="stylesheet", `type`="text/css", href="/style.css")
+
+    body:
+      nav(id="nav", class="nav-bar container"):
+        tdiv(class="inner-nav"):
+          tdiv(class="item"):
+            a(href="/", class="site-name"): text "nitter"
+
+      tdiv(id="content", class="container"):
+        body
+
+  result = doctype & $node
+
+proc renderSearch*(): VNode =
+  buildHtml(tdiv(class="panel")):
+    tdiv(class="search-panel"):
+      form(`method`="post", action="search"):
+        input(`type`="text", name="query", placeholder="Enter username...")
+        button(`type`="submit"): text "🔎"
+
+proc renderError*(error: string): VNode =
+  buildHtml(tdiv(class="panel")):
+    tdiv(class="error-panel"):
+      span: text error
+
+proc showError*(error: string): string =
+  renderMain(renderError(error), title = "Error | Nitter")

+ 0 - 49
src/views/general.nimf

@@ -1,49 +0,0 @@
-#? stdtmpl(subsChar = '$', metaChar = '#')
-#import xmltree
-#
-#proc renderMain*(body: string; title="Nitter"): string =
-<!DOCTYPE html>
-<html lang="en">
-  <head>
-    <title>${xmltree.escape(title)}</title>
-    <link rel="stylesheet" type="text/css" href="/style.css">
-  </head>
-
-  <body>
-    <nav id="nav" class="nav-bar container">
-      <div class="inner-nav">
-        <div class="item">
-          <a href="/" class="site-name">nitter</a>
-        </div>
-      </div>
-    </nav>
-
-    <div id="content" class="container">
-      ${body}
-    </div>
-  </body>
-</html>
-#end proc
-#
-#proc renderSearchPanel*(): string =
-<div class="panel">
-  <div class="search-panel">
-    <form action="search" method="post">
-      <input type="text" name="query" placeholder="Enter username...">
-      <button type="submit" name="button">🔎</button>
-    </form>
-  </div>
-</div>
-#end proc
-#
-#proc renderError*(error: string): string =
-<div class="panel">
-  <div class="error-panel">
-    <span>${error}</span>
-  </div>
-</div>
-#end proc
-#
-#proc showError*(error: string): string =
-#renderMain(renderError(error), title="Error | Nitter")
-#end proc

+ 66 - 0
src/views/profile.nim

@@ -0,0 +1,66 @@
+import strutils, strformat
+import karax/[karaxdsl, vdom, vstyles]
+
+import ../types, ../utils, ../formatters
+import tweet, timeline, renderutils
+
+proc renderStat(stat, text: string): VNode =
+  buildHtml(li(class=text)):
+    span(class="profile-stat-header"): text capitalizeAscii(text)
+    span(class="profile-stat-num"): text stat
+
+proc renderProfileCard*(profile: Profile): VNode =
+  buildHtml(tdiv(class="profile-card")):
+    a(class="profile-card-avatar", href=profile.getUserPic().getSigUrl("pic")):
+      genImg(profile.getUserpic("_200x200"))
+
+    tdiv(class="profile-card-tabs"):
+      tdiv(class="profile-card-tabs-name"):
+        linkUser(profile, class="profile-card-fullname")
+        linkUser(profile, class="profile-card-username")
+
+    tdiv(class="profile-card-extra"):
+      if profile.bio.len > 0:
+        tdiv(class="profile-bio"):
+          p: verbatim linkifyText(profile.bio)
+
+      tdiv(class="profile-card-extra-links"):
+        ul(class="profile-statlist"):
+          renderStat(profile.tweets, "tweets")
+          renderStat(profile.followers, "followers")
+          renderStat(profile.following, "following")
+
+proc renderPhotoRail(username: string; photoRail: seq[GalleryPhoto]): VNode =
+  buildHtml(tdiv(class="photo-rail-card")):
+    tdiv(class="photo-rail-header"):
+      a(href=(&"/{username}/media")):
+        text "🖼 Photos and videos"
+
+    tdiv(class="photo-rail-grid"):
+      for i, photo in photoRail:
+        if i == 16: break
+        a(href=(&"/{username}/status/{photo.tweetId}"),
+          style={backgroundColor: photo.color}):
+          genImg(photo.url & ":thumb")
+
+proc renderBanner(profile: Profile): VNode =
+  buildHtml():
+    if "#" in profile.banner:
+      tdiv(class="profile-banner-color", style={backgroundColor: profile.banner})
+    else:
+      a(href=getSigUrl(profile.banner, "pic")):
+        genImg(profile.banner)
+
+proc renderProfile*(profile: Profile; timeline: Timeline;
+                    photoRail: seq[GalleryPhoto]): VNode =
+  buildHtml(tdiv(class="profile-tabs")):
+    tdiv(class="profile-banner"):
+      renderBanner(profile)
+
+    tdiv(class="profile-tab"):
+      renderProfileCard(profile)
+      if photoRail.len > 0:
+        renderPhotoRail(profile.username, photoRail)
+
+    tdiv(class="timeline-tab"):
+      renderTimeline(timeline, profile)

+ 21 - 0
src/views/renderutils.nim

@@ -0,0 +1,21 @@
+import karax/[karaxdsl, vdom, vstyles]
+
+import ../types, ../utils
+
+proc linkUser*(profile: Profile, class=""): VNode =
+  let
+    isName = "username" notin class
+    href = "/" & profile.username
+    nameText = if isName: profile.fullname
+               else: "@" & profile.username
+
+  buildHtml(a(href=href, class=class, title=nameText)):
+    text nameText
+    if isName and profile.verified:
+      span(class="icon verified-icon", title="Verified account"): text "✔"
+    if isName and profile.protected:
+      span(class="icon protected-icon", title="Protected account"): text "🔒"
+
+proc genImg*(url: string; class=""): VNode =
+  buildHtml():
+    img(src=url.getSigUrl("pic"), class=class, alt="Image")

+ 42 - 0
src/views/status.nim

@@ -0,0 +1,42 @@
+import strutils, strformat
+import karax/[karaxdsl, vdom]
+
+import ../types
+import tweet, renderutils
+
+proc renderReplyThread(thread: Thread): 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)
+
+    if thread.more != 0:
+      let num = if thread.more != -1: $thread.more & " " else: ""
+      let reply = if thread.more == 1: "reply" else: "replies"
+      tdiv(class="status-el more-replies"):
+        a(class="more-replies-text", title="Not implemented yet"):
+          text $num & "more " & reply
+
+proc renderConversation*(conversation: Conversation): VNode =
+  let hasAfter = conversation.after != nil
+  buildHtml(tdiv(class="conversation", id="tweets")):
+    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)
+
+      tdiv(class="main-tweet"):
+        let afterClass = if hasAfter: "thread thread-line" else: ""
+        renderTweet(conversation.tweet, 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)
+
+    if conversation.replies.len > 0:
+      tdiv(class="replies"):
+        for thread in conversation.replies:
+          renderReplyThread(thread)

+ 93 - 0
src/views/timeline.nim

@@ -0,0 +1,93 @@
+import strutils, strformat, algorithm, times
+import karax/[karaxdsl, vdom, vstyles]
+
+import ../types, ../search
+import tweet, renderutils
+
+proc getQuery(timeline: Timeline): string =
+  if timeline.query.isNone: "?"
+  else: genQueryUrl(get(timeline.query))
+
+proc getTabClass(timeline: Timeline; tab: string): string =
+  var classes = @["tab-item"]
+
+  if timeline.query.isNone:
+    if tab == "tweets":
+      classes.add "active"
+  elif $timeline.query.get().queryType == tab:
+    classes.add "active"
+
+  return classes.join(" ")
+
+proc renderSearchTabs(timeline: Timeline; profile: Profile): VNode =
+  let link = "/" & profile.username
+  buildHtml(ul(class="tab")):
+    li(class=timeline.getTabClass("tweets")):
+      a(href=link): text "Tweets"
+    li(class=timeline.getTabClass("replies")):
+      a(href=(link & "/replies")): text "Tweets & Replies"
+    li(class=timeline.getTabClass("media")):
+      a(href=(link & "/media")): text "Media"
+
+proc renderNewer(timeline: Timeline; profile: Profile): VNode =
+  buildHtml(tdiv(class="status-el show-more")):
+    a(href=("/" & profile.username & getQuery(timeline).strip(chars={'?'}))):
+      text "Load newest tweets"
+
+proc renderOlder(timeline: Timeline; profile: Profile): VNode =
+  buildHtml(tdiv(class="show-more")):
+    a(href=(&"/{profile.username}{getQuery(timeline)}after={timeline.minId}")):
+      text "Load older tweets"
+
+proc renderNoMore(): VNode =
+  buildHtml(tdiv(class="timeline-footer")):
+    h2(class="timeline-end", style={textAlign: "center"}):
+      text "No more tweets."
+
+proc renderNoneFound(): VNode =
+  buildHtml(tdiv(class="timeline-header")):
+    h2(class="timeline-none", style={textAlign: "center"}):
+      text "No tweets found."
+
+proc renderProtected(username: string): VNode =
+  buildHtml(tdiv(class="timeline-header timeline-protected")):
+    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 =
+  buildHtml(tdiv(class="timeline-tweet thread-line")):
+    for i, threadTweet in thread.sortedByIt(it.time):
+      renderTweet(threadTweet, "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 =
+  buildHtml(tdiv(id="tweets")):
+    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")
+      else:
+        renderThread(thread)
+        threads &= tweet.threadId
+
+proc renderTimeline*(timeline: Timeline; profile: Profile): VNode =
+  buildHtml(tdiv):
+    renderSearchTabs(timeline, profile)
+
+    if not profile.protected and not timeline.beginning:
+      renderNewer(timeline, profile)
+
+    if profile.protected:
+      renderProtected(profile.username)
+    elif timeline.tweets.len == 0:
+      renderNoneFound()
+    else:
+      renderTweets(timeline)
+      if timeline.hasMore or timeline.query.isSome:
+        renderOlder(timeline, profile)
+      else:
+        renderNoMore()

+ 176 - 0
src/views/tweet.nim

@@ -0,0 +1,176 @@
+import strutils
+import karax/[karaxdsl, vdom, vstyles]
+
+import ../types, ../utils, ../formatters
+import renderutils
+
+proc renderHeader(tweet: Tweet): VNode =
+  buildHtml(tdiv):
+    if tweet.retweet.isSome:
+      tdiv(class="retweet"):
+        span: text "🔄 " & get(tweet.retweet).by & " retweeted"
+    if tweet.pinned:
+      tdiv(class="pinned"):
+        span: text "📌 Pinned Tweet"
+
+    tdiv(class="tweet-header"):
+      tdiv(class="tweet-name-row"):
+        a(class="tweet-avatar", href=("/" & tweet.profile.username)):
+          genImg(tweet.profile.getUserpic("_bigger"), class="avatar")
+
+        tdiv(class="fullname-and-username"):
+          linkUser(tweet.profile, class="fullname")
+          linkUser(tweet.profile, class="username")
+
+        span(class="tweet-date"):
+          a(href=getLink(tweet), title=tweet.getTime()):
+            text tweet.shortTime
+
+proc renderAlbum(tweet: Tweet): VNode =
+  let
+    groups = if tweet.photos.len < 3: @[tweet.photos]
+             else: tweet.photos.distribute(2)
+    class = if groups.len == 1 and groups[0].len == 1: "single-image"
+            else: ""
+
+  buildHtml(tdiv(class=("attachments " & class))):
+    for i, photos in groups:
+      let margin = if i > 0: ".25em" else: ""
+      let flex = if photos.len > 1 or groups.len > 1: "flex" else: "block"
+      tdiv(class="gallery-row", style={marginTop: margin}):
+        for photo in photos:
+          tdiv(class="attachment image"):
+            a(href=getSigUrl(photo & "?name=orig", "pic"), class="still-image",
+              target="_blank", style={display: flex}):
+              genImg(photo)
+
+proc renderVideo(video: Video): VNode =
+  buildHtml(tdiv(class="attachments")):
+    tdiv(class="gallery-video"):
+      tdiv(class="attachment video-container"):
+        case video.playbackType
+        of mp4:
+          video(poster=video.thumb.getSigUrl("pic"), controls=""):
+            source(src=video.url.getSigUrl("video"), `type`="video/mp4")
+        of m3u8, vmap:
+          video(poster=video.thumb.getSigUrl("pic"))
+          tdiv(class="video-overlay"):
+            p: text "Video playback not supported"
+
+proc renderGif(gif: Gif): 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")
+
+proc renderPoll(poll: Poll): VNode =
+  buildHtml(tdiv(class="poll")):
+    for i in 0 ..< poll.options.len:
+      let leader = if poll.leader == i: " leader" else: ""
+      let perc = $poll.values[i] & "%"
+      tdiv(class=("poll-meter" & leader)):
+        span(class="poll-choice-bar", style=style(width, perc))
+        span(class="poll-choice-value"): text perc
+        span(class="poll-choice-option"): text poll.options[i]
+    span(class="poll-info"):
+      text $poll.votes & " votes • " & poll.status
+
+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
+
+proc renderReply(tweet: Tweet): VNode =
+  buildHtml(tdiv(class="replying-to")):
+    text "Replying to "
+    for i, u in tweet.reply:
+      if i > 0: text " "
+      a(href=("/" & u)): text "@" & u
+
+proc renderReply(quote: Quote): VNode =
+  buildHtml(tdiv(class="replying-to")):
+    text "Replying to "
+    for i, u in quote.reply:
+      if i > 0: text " "
+      a(href=("/" & u)): text "@" & u
+
+proc renderQuoteMedia(quote: Quote): VNode =
+  buildHtml(tdiv(class="quote-media-container")):
+    if quote.thumb.len > 0:
+      tdiv(class="quote-media"):
+        genImg(quote.thumb)
+        if quote.badge.len > 0:
+          tdiv(class="quote-badge"):
+            tdiv(class="quote-badge-text"): text quote.badge
+    elif quote.sensitive:
+      tdiv(class="quote-sensitive"):
+        span(class="icon quote-sensitive-icon"): text "❗"
+
+proc renderQuote(quote: Quote): VNode =
+  if not quote.available:
+    return buildHtml(tdiv(class="quote unavailable")):
+      tdiv(class="unavailable-quote"):
+        text "This tweet is unavailable"
+
+  buildHtml(tdiv(class="quote")):
+    a(class="quote-link", href=getLink(quote))
+
+    if quote.thumb.len > 0 or quote.sensitive:
+      renderQuoteMedia(quote)
+
+    tdiv(class="fullname-and-username"):
+      linkUser(quote.profile, class="fullname")
+      linkUser(quote.profile, class="username")
+
+    if quote.reply.len > 0:
+      renderReply(quote)
+
+    tdiv(class="quote-text"):
+      verbatim linkifyText(quote.text)
+
+    if quote.hasThread:
+      a(href=getLink(quote)):
+        text "Show this thread"
+
+proc renderTweet*(tweet: Tweet; class=""; index=0; total=(-1); last=false): VNode =
+  var divClass = class
+  if index == total or last:
+    divClass = "thread-last " & class
+
+  if not tweet.available:
+    return buildHtml(tdiv(class=divClass)):
+      tdiv(class="status-el unavailable"):
+        tdiv(class="unavailable-box"):
+          text "This tweet is unavailable"
+
+  buildHtml(tdiv(class=divClass)):
+    tdiv(class="status-el"):
+      tdiv(class="status-body"):
+        renderHeader(tweet)
+
+        if index == 0 and tweet.reply.len > 0:
+          renderReply(tweet)
+
+        tdiv(class="status-content media-body"):
+          verbatim linkifyText(tweet.text)
+
+        if tweet.quote.isSome:
+          renderQuote(tweet.quote.get())
+
+        if tweet.photos.len > 0:
+          renderAlbum(tweet)
+        elif tweet.video.isSome:
+          renderVideo(tweet.video.get())
+        elif tweet.gif.isSome:
+          renderGif(tweet.gif.get())
+        elif tweet.poll.isSome:
+          renderPoll(tweet.poll.get())
+
+        renderStats(tweet.stats)
+
+        if tweet.hasThread and "timeline" in class:
+          a(href=getLink(tweet)):
+            text "Show this thread"

+ 0 - 204
src/views/tweet.nimf

@@ -1,204 +0,0 @@
-#? stdtmpl(subsChar = '$', metaChar = '#')
-#import xmltree, strutils, strformat, sequtils, times, uri
-#import ../types, ../formatters, ../utils
-#
-#proc renderHeading(tweet: Tweet): string =
-#if tweet.retweet.isSome:
-  <div class="retweet">
-    <span>🔄 ${get(tweet.retweet).by} retweeted</span>
-  </div>
-#end if
-#if tweet.pinned:
-  <div class="pinned">
-    <span>📌 Pinned Tweet</span>
-  </div>
-#end if
-<div class="media-heading">
-  <div class="heading-name-row">
-    <a class="tweet-avatar" href="/${tweet.profile.username}">
-      ${genImg(tweet.profile.getUserpic("_bigger"), "avatar")}
-    </a>
-    <div class="fullname-and-username">
-      ${linkUser(tweet.profile, class="fullname")}
-      ${linkUser(tweet.profile, class="username")}
-    </div>
-    <span class="heading-right">
-      <a href="${getLink(tweet)}" title="${tweet.getTime()}">${tweet.shortTime}</a>
-    </span>
-  </div>
-</div>
-#end proc
-#
-#proc renderMediaGroup(tweet: Tweet): string =
-#let groups = if tweet.photos.len > 2: tweet.photos.distribute(2) else: @[tweet.photos]
-#let class = if groups.len == 1 and groups[0].len == 1: "single-image" else: ""
-#var first = true
-<div class="attachments ${class}">
-#for photos in groups:
-  #let margin = if not first: "margin-top: .25em;" else: ""
-  #let flex = if photos.len > 1 or groups.len > 1: "display: flex;" else: ""
-  <div class="gallery-row cover-fit" style="${margin}">
-    #for photo in photos:
-    <div class="attachment image">
-      ##TODO: why doesn't this work?
-      <a href=${getSigUrl(photo & "?name=orig", "pic")} target="_blank" class="image-attachment">
-        <div class="still-image" style="${flex}">
-          ${genImg(photo)}
-        </div>
-      </a>
-    </div>
-    #end for
-  </div>
-  #first = false
-#end for
-</div>
-#end proc
-#
-#proc renderVideo(video: Video): string =
-<div class="attachments">
-  <div class="gallery-video">
-    <div class="attachment video-container">
-    #case video.playbackType
-    #of mp4:
-    <video poster=${video.thumb.getSigUrl("pic")} controls>
-      <source src=${video.url.getSigUrl("video")} type="video/mp4">
-    </video>
-    #of m3u8, vmap:
-    <video poster=${video.thumb.getSigUrl("pic")} autoplay muted loop></video>
-    <div class="video-overlay">
-      <p>Video playback not supported</p>
-    </div>
-    #end case
-    </div>
-  </div>
-</div>
-#end proc
-#
-#proc renderGif(gif: Gif): string =
-<div class="attachments media-gif">
-  <div class="gallery-gif" style="max-height: unset;">
-    <div class="attachment">
-      <video class="gif" poster=${gif.thumb.getSigUrl("pic")} autoplay muted loop>
-        <source src=${gif.url.getSigUrl("video")} type="video/mp4">
-      </video>
-    </div>
-  </div>
-</div>
-#end proc
-#
-#proc renderPoll(poll: Poll): string =
-<div class="poll">
-  #for i in 0 ..< poll.options.len:
-  #let leader = if poll.leader == i: " leader" else: ""
-  <div class="poll-meter${leader}">
-    <span class="poll-choice-bar" style="width: ${poll.values[i]}%"></span>
-    <span class="poll-choice-value">${poll.values[i]}%</span>
-    <span class="poll-choice-option">${poll.options[i]}</span>
-  </div>
-  #end for
-  <span class="poll-info">${poll.votes} votes • ${poll.status}</span>
-</div>
-#end proc
-#
-#proc renderStats(stats: TweetStats): string =
-<div class="tweet-stats">
-  <span class="tweet-stat">💬 ${$stats.replies}</span>
-  <span class="tweet-stat">🔄 ${$stats.retweets}</span>
-  <span class="tweet-stat">👍 ${$stats.likes}</span>
-</div>
-#end proc
-#
-#proc renderShowThread(tweet: Tweet | Quote): string =
-<a href="${getLink(tweet)}">Show this thread</a>
-#end proc
-#
-#proc renderReply(tweet: Tweet | Quote): string =
-#let usernames = tweet.reply.mapIt(&"""<a href="/{it}">@{it}</a>""")
-<div class="replying-to">Replying to ${usernames.join(" ")}</div>
-#end proc
-#
-#proc renderQuote(quote: Quote): string =
-#let hasMedia = quote.thumb.len > 0 or quote.sensitive
-#if not quote.available:
-<div class="quote unavailable">
-  <div class="unavailable-quote">This tweet is unavailable</div>
-</div>
-#return
-#end if
-<div class="quote">
-  <div class="quote-container">
-    <a class="quote-link" href="${getLink(quote)}"></a>
-    #if hasMedia:
-    <div class="quote-media-container">
-      <div class="quote-media">
-        #if quote.thumb.len > 0:
-        ${genImg(quote.thumb)}
-        #if quote.badge.len > 0:
-        <div class="quote-badge">
-          <div class="quote-badge-text">${quote.badge}</div>
-        </div>
-        #end if
-        #elif quote.sensitive:
-        <div class="quote-sensitive">
-          <span class="icon quote-sensitive-icon">❗</span>
-        </div>
-        #end if
-      </div>
-    </div>
-    #end if
-    <div class="fullname-and-username">
-      ${linkUser(quote.profile, class="fullname")}
-      ${linkUser(quote.profile, class="username")}
-    </div>
-    #if quote.reply.len > 0:
-    ${renderReply(quote)}
-    #end if
-    <div class="quote-text">${linkifyText(quote.text)}</div>
-    #if quote.hasThread:
-    ${renderShowThread(quote)}
-    #end if
-  </div>
-</div>
-#end proc
-#
-#proc renderTweet*(tweet: Tweet; class=""; first=true; last=false): string =
-#var divClass = if last: "thread-last " & class else: class
-#if divClass.len > 0:
-<div class="${divClass}">
-#end if
-#if tweet.available:
-<div class="status-el">
-  <div class="status-body">
-    ${renderHeading(tweet)}
-    #if first and tweet.reply.len > 0:
-    ${renderReply(tweet)}
-    #end if
-    <div class="status-content-wrapper">
-      <div class="status-content media-body">${linkifyText(tweet.text)}</div>
-    </div>
-    #if tweet.photos.len > 0:
-    ${renderMediaGroup(tweet)}
-    #elif tweet.video.isSome:
-    ${renderVideo(tweet.video.get())}
-    #elif tweet.gif.isSome:
-    ${renderGif(tweet.gif.get())}
-    #elif tweet.quote.isSome:
-    ${renderQuote(tweet.quote.get())}
-    #elif tweet.poll.isSome:
-    ${renderPoll(tweet.poll.get())}
-    #end if
-    ${renderStats(tweet.stats)}
-    #if tweet.hasThread and "timeline" in class:
-    ${renderShowThread(tweet)}
-    #end if
-  </div>
-</div>
-#else:
-<div class="status-el unavailable">
-  <div class="unavailable-box">This tweet is unavailable</div>
-</div>
-#end if
-#if divClass.len > 0:
-</div>
-#end if
-#end proc

+ 0 - 192
src/views/user.nimf

@@ -1,192 +0,0 @@
-#? stdtmpl(subsChar = '$', metaChar = '#')
-#import xmltree, strutils, uri, algorithm
-#import ../types, ../formatters, ../utils, ../search
-#include "tweet.nimf"
-#
-#proc renderProfileCard*(profile: Profile): string =
-<div class="profile-card">
-  <a class="profile-card-avatar" href="${profile.getUserPic().getSigUrl("pic")}">
-    ${genImg(profile.getUserpic("_200x200"))}
-  </a>
-  <div class="profile-card-tabs">
-    <div class="profile-card-tabs-name">
-      ${linkUser(profile, class="profile-card-fullname")}
-      ${linkUser(profile, class="profile-card-username")}
-    </div>
-  </div>
-  <div class="profile-card-extra">
-    #if profile.bio.len > 0:
-    <div class="profile-bio">
-      <p>${linkifyText(profile.bio)}</p>
-    </div>
-    #end if
-
-    <div class="profile-card-extra-links">
-      <ul class="profile-statlist">
-        <li class="tweets">
-          <span class="profile-stat-header">Tweets</span>
-          <span class="profile-stat-num">${$profile.tweets}</span>
-        </li>
-        <li class="followers">
-          <span class="profile-stat-header">Followers</span>
-          <span class="profile-stat-num">${$profile.followers}</span>
-        </li>
-        <li class="following">
-          <span class="profile-stat-header">Following</span>
-          <span class="profile-stat-num">${$profile.following}</span>
-        </li>
-      </ul>
-    </div>
-  </div>
-</div>
-#end proc
-#
-#proc renderPhotoRail(username: string; photoRail: seq[GalleryPhoto]): string =
-<div class="photo-rail-card">
-  <div class="photo-rail-heading">
-    <a href="/${username}/media">🖼 Photos and videos</a>
-  </div>
-  <div class="photo-rail-grid">
-    #for i, photo in photoRail:
-    #if i == 20: break
-    #end if
-    <a href="/${username}/status/${photo.tweetId}" style="${photo.color}">
-      <img src=${getSigUrl(photo.url & ":thumb", "pic")}></img>
-    </a>
-    #end for
-  </div>
-</div>
-#end proc
-#
-#proc renderBanner(profile: Profile): string =
-#if "#" in profile.banner:
-<div style="${profile.banner}" class="profile-banner-color"></div>
-#else:
-#let url = getSigUrl(profile.banner, "pic")
-<a href="${url}">${genImg(profile.banner)}</a>
-#end if
-#end proc
-#
-#proc renderTimeline*(timeline: Timeline; profile: Profile; beginning: bool): string =
-#var threads: seq[string]
-#var query = "?"
-#if timeline.query.isSome: query = genQueryUrl(get(timeline.query))
-#end if
-<div id="tweets">
-  #if not beginning:
-  <div class="show-more status-el">
-    <a href="/${profile.username}${query.strip(chars={'?'})}">Load newest tweets</a>
-  </div>
-  #end if
-  #
-  #for tweet in timeline.tweets:
-  #if tweet.threadId in threads: continue
-  #end if
-  #proc threadFilter(it: Tweet): bool =
-  #it.retweet.isNone and it.reply.len == 0 and it.threadId == tweet.threadId
-  #end proc
-  #let thread = timeline.tweets.filter(threadFilter)
-  #if thread.len < 2:
-  ${renderTweet(tweet, "timeline-tweet")}
-  #else:
-  <div class="thread-line">
-    #for i, threadTweet in thread.sortedByIt(it.time):
-    #let last = (i == thread.high)
-    #let class = if last: "timeline-tweet" else: "thread"
-    ${renderTweet(threadTweet, class, first=(i == 0), last=last)}
-    #end for
-  </div>
-  #threads.add tweet.threadId
-  #end if
-  #end for
-  #
-  #if timeline.hasMore or timeline.query.isSome and timeline.tweets.len > 0:
-  <div class="show-more">
-    <a href="/${profile.username}${query}after=${timeline.minId}">Load older tweets</a>
-  </div>
-  #elif timeline.tweets.len > 0:
-  <div class="timeline-footer">
-    <h2 class="timeline-end" style="text-align: center;">No more tweets.</h2>
-  </div>
-  #else:
-  <div class="timeline-header">
-    #if profile.protected:
-    <h2 class="timeline-protected">This account's tweets are protected.</h2>
-    <p>Only confirmed followers have access to @${profile.username}'s tweets.</p>
-    #else:
-    <h2 class="timeline-none" style="text-align: center;">No tweets found.</h2>
-    #end if
-  </div>
-  #end if
-</div>
-#end proc
-#
-#proc renderProfile*(profile: Profile; timeline: Timeline;
-#                    photoRail: seq[GalleryPhoto]; beginning: bool): string =
-<div class="profile-tabs">
-  <div class="profile-banner">
-    ${renderBanner(profile)}
-  </div>
-  <div class="profile-tab">
-    ${renderProfileCard(profile)}
-    #if photoRail.len > 0:
-    ${renderPhotoRail(profile.username, photoRail)}
-    #end if
-  </div>
-  <div class="timeline-tab">
-    #let link = "/" & profile.username
-    <ul class="tab">
-      <li class=${timeline.tabClass("tweets")}><a href="${link}">Tweets</a></li>
-      <li class=${timeline.tabClass("replies")}><a href="${link}/replies">Tweets & Replies</a></li>
-      <li class=${timeline.tabClass("media")}><a href="${link}/media">Media</a></li>
-      #discard "<li class=tab-item><a href=${link}/search>Custom</a></li>"
-    </ul>
-    ${renderTimeline(timeline, profile, beginning)}
-  </div>
-</div>
-#end proc
-#
-#proc renderConversation*(conversation: Conversation): string =
-<div class="conversation" id="tweets">
-  <div class="main-thread">
-    #if conversation.before != nil:
-    <div class="before-tweet thread-line">
-      #for i, tweet in conversation.before.tweets:
-        ${renderTweet(tweet, first=(i == 0))}
-      #end for
-    </div>
-    #end if
-    <div class="main-tweet">
-      #let afterClass = if conversation.after != nil: "thread thread-line" else: ""
-      ${renderTweet(conversation.tweet, class=afterClass)}
-    </div>
-    #if conversation.after != nil:
-    <div class="after-tweet thread-line">
-      #for i, tweet in conversation.after.tweets:
-      ${renderTweet(tweet, first=(i == 0), last=(i == conversation.after.tweets.high))}
-      #end for
-    </div>
-    #end if
-  </div>
-  #if conversation.replies.len > 0:
-  <div class="replies">
-    #for thread in conversation.replies:
-    <div class="reply thread thread-line">
-      #for i, tweet in thread.tweets:
-      #let last = (i == thread.tweets.high and thread.more == 0)
-      ${renderTweet(tweet, first=(i == 0), last=last)}
-      #end for
-      #if thread.more != 0:
-      #let num = if thread.more != -1: $thread.more & " " else: ""
-      <div class="status-el more-replies">
-        #let reply = if thread.more == 1: "reply" else: "replies"
-        <a class="more-replies-text" title="Not implemented yet">${num}more ${reply}</a>
-      </div>
-      #end if
-    </div>
-    #end for
-  </div>
-  #end if
-  </div>
-</div>
-#end proc

+ 4 - 4
tests/base.py

@@ -3,11 +3,11 @@ from seleniumbase import BaseCase
 
 class Tweet(object):
     def __init__(self, tweet=''):
-        namerow = tweet + 'div.media-heading '
+        namerow = tweet + '.tweet-header '
         self.fullname = namerow + '.fullname'
         self.username = namerow + '.username'
-        self.date = tweet + 'div.media-heading .heading-right'
-        self.text = tweet + '.status-content-wrapper .status-content.media-body'
+        self.date = namerow + '.tweet-date'
+        self.text = tweet + '.status-content.media-body'
         self.retweet = tweet = '.retweet'
 
 
@@ -21,7 +21,7 @@ class Profile(object):
 
 
 class Timeline(object):
-    newest = 'div[class="show-more status-el"]'
+    newest = 'div[class="status-el show-more"]'
     older = 'div[class="show-more"]'
     end = '.timeline-end'
     none = '.timeline-none'

+ 2 - 2
tests/test_profile.py

@@ -10,8 +10,8 @@ profiles = [
 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'], ['%']]

+ 2 - 2
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']
 ]