Use Karax html rendering instead of source filters (#2)
* Use Karax html rendering instead of source filters
This commit is contained in:
parent
fad2575d93
commit
ab36664ad2
|
@ -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"
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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"
|
||||||
|
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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: ", "")
|
||||||
)
|
)
|
||||||
|
|
|
@ -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"):
|
||||||
|
|
|
@ -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 &= '"'
|
|
||||||
|
|
|
@ -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 =
|
||||||
|
|
|
@ -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")
|
|
@ -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
|
|
|
@ -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)
|
|
@ -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")
|
|
@ -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)
|
|
@ -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()
|
|
@ -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"
|
|
@ -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
|
|
|
@ -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
|
|
|
@ -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'
|
||||||
|
|
|
@ -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'], ['%']]
|
||||||
|
|
|
@ -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']
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue