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

* Use Karax html rendering instead of source filters
This commit is contained in:
Zed 2019-07-11 00:42:31 +02:00 committed by GitHub
parent fad2575d93
commit ab36664ad2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 482 additions and 507 deletions

View File

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

View File

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

View File

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

View File

@ -70,23 +70,6 @@ proc getUserpic*(userpic: string; style=""): string =
proc getUserpic*(profile: Profile; style=""): string = proc getUserpic*(profile: Profile; style=""): string =
getUserPic(profile.userpic, style) 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 = proc pageTitle*(profile: Profile): string =
&"{profile.fullname} (@{profile.username}) | Nitter" &"{profile.fullname} (@{profile.username}) | Nitter"

View File

@ -3,8 +3,7 @@ import jester, regex
import api, utils, types, cache, formatters, search import api, utils, types, cache, formatters, search
include views/"user.nimf" import views/[general, profile, status]
include views/"general.nimf"
const cacheDir {.strdefine.} = "/tmp/nitter" 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: if profile.username.len == 0:
return "" 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)) return renderMain(profileHtml, title=pageTitle(profile))
template respTimeline(timeline: typed) = template respTimeline(timeline: typed) =
@ -34,7 +33,7 @@ template respTimeline(timeline: typed) =
routes: routes:
get "/": get "/":
resp renderMain(renderSearchPanel(), title=pageTitle("Search")) resp renderMain(renderSearch(), title=pageTitle("Search"))
post "/search": post "/search":
if @"query".len == 0: if @"query".len == 0:

View File

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

View File

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

View File

@ -81,12 +81,3 @@ proc cleanPos*(pos: string): string =
proc genPos*(pos: string): string = proc genPos*(pos: string): string =
posPrefix & pos & posSuffix 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 &= '"'

View File

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

35
src/views/general.nim Normal file
View File

@ -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")

View File

@ -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
src/views/profile.nim Normal file
View File

@ -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
src/views/renderutils.nim Normal file
View File

@ -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
src/views/status.nim Normal file
View File

@ -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
src/views/timeline.nim Normal file
View File

@ -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
src/views/tweet.nim Normal file
View File

@ -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"

View File

@ -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

View File

@ -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

View File

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

View File

@ -10,8 +10,8 @@ profiles = [
verified = [['jack'], ['elonmusk']] verified = [['jack'], ['elonmusk']]
protected = [ protected = [
['mobile_test_7', 'mobile test 7', ''], ['mobile_test_7', 'mobile test 7🔒', ''],
['Poop', 'Randy', 'Social media fanatic.'] ['Poop', 'Randy🔒', 'Social media fanatic.']
] ]
invalid = [['thisprofiledoesntexist'], ['%']] invalid = [['thisprofiledoesntexist'], ['%']]

View File

@ -16,7 +16,7 @@ timeline = [
] ]
status = [ 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'], [134849778302464000, 'The Twoffice', 'TheTwoffice', '10 Nov 2011', 'test'],
[105685475985080322, 'The Twoffice', 'TheTwoffice', '22 Aug 2011', 'regular tweet'], [105685475985080322, 'The Twoffice', 'TheTwoffice', '22 Aug 2011', 'regular tweet'],
[572593440719912960, 'Test account', 'mobile_test', '2 Mar 2015', 'testing test'] [572593440719912960, 'Test account', 'mobile_test', '2 Mar 2015', 'testing test']
@ -77,7 +77,7 @@ emoji = [
retweet = [ retweet = [
[7, 'mobile_test_2', 'mobile test 2', 'Test account', '@mobile_test', '1234'], [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']
] ]