diff --git a/nitter.nimble b/nitter.nimble index 3d2eaea..039b7d0 100644 --- a/nitter.nimble +++ b/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" diff --git a/public/style.css b/public/style.css index f4c5375..d3c5799 100644 --- a/public/style.css +++ b/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 { diff --git a/src/api.nim b/src/api.nim index 5c334fa..4f36dd8 100644 --- a/src/api.nim +++ b/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) diff --git a/src/formatters.nim b/src/formatters.nim index fe78d69..56b366f 100644 --- a/src/formatters.nim +++ b/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" diff --git a/src/nitter.nim b/src/nitter.nim index cdfa536..71ba075 100644 --- a/src/nitter.nim +++ b/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: diff --git a/src/parser.nim b/src/parser.nim index 0dea171..5deb817 100644 --- a/src/parser.nim +++ b/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: ", "") ) diff --git a/src/parserutils.nim b/src/parserutils.nim index 5d808f9..35a086c 100644 --- a/src/parserutils.nim +++ b/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"): diff --git a/src/search.nim b/src/search.nim index a178a07..89109e0 100644 --- a/src/search.nim +++ b/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 &= '"' diff --git a/src/types.nim b/src/types.nim index cc9e45e..4aac3b5 100644 --- a/src/types.nim +++ b/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 = diff --git a/src/views/general.nim b/src/views/general.nim new file mode 100644 index 0000000..1c5165a --- /dev/null +++ b/src/views/general.nim @@ -0,0 +1,35 @@ +import karax/[karaxdsl, vdom] + +const doctype = "\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") diff --git a/src/views/general.nimf b/src/views/general.nimf deleted file mode 100644 index 3f3fee4..0000000 --- a/src/views/general.nimf +++ /dev/null @@ -1,49 +0,0 @@ -#? stdtmpl(subsChar = '$', metaChar = '#') -#import xmltree -# -#proc renderMain*(body: string; title="Nitter"): string = - - - - ${xmltree.escape(title)} - - - - - - -
- ${body} -
- - -#end proc -# -#proc renderSearchPanel*(): string = -
-
-
- - -
-
-
-#end proc -# -#proc renderError*(error: string): string = -
-
- ${error} -
-
-#end proc -# -#proc showError*(error: string): string = -#renderMain(renderError(error), title="Error | Nitter") -#end proc diff --git a/src/views/profile.nim b/src/views/profile.nim new file mode 100644 index 0000000..9255acc --- /dev/null +++ b/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) diff --git a/src/views/renderutils.nim b/src/views/renderutils.nim new file mode 100644 index 0000000..2f501f0 --- /dev/null +++ b/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") diff --git a/src/views/status.nim b/src/views/status.nim new file mode 100644 index 0000000..a36e9f9 --- /dev/null +++ b/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) diff --git a/src/views/timeline.nim b/src/views/timeline.nim new file mode 100644 index 0000000..cb1d32e --- /dev/null +++ b/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() diff --git a/src/views/tweet.nim b/src/views/tweet.nim new file mode 100644 index 0000000..3ffd50e --- /dev/null +++ b/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" diff --git a/src/views/tweet.nimf b/src/views/tweet.nimf deleted file mode 100644 index 630bc09..0000000 --- a/src/views/tweet.nimf +++ /dev/null @@ -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: -
- πŸ”„ ${get(tweet.retweet).by} retweeted -
-#end if -#if tweet.pinned: -
- πŸ“Œ Pinned Tweet -
-#end if -
-
- - ${genImg(tweet.profile.getUserpic("_bigger"), "avatar")} - -
- ${linkUser(tweet.profile, class="fullname")} - ${linkUser(tweet.profile, class="username")} -
- - ${tweet.shortTime} - -
-
-#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 -
-#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: "" - - #first = false -#end for -
-#end proc -# -#proc renderVideo(video: Video): string = -
- -
-#end proc -# -#proc renderGif(gif: Gif): string = -
- -
-#end proc -# -#proc renderPoll(poll: Poll): string = -
- #for i in 0 ..< poll.options.len: - #let leader = if poll.leader == i: " leader" else: "" -
- - ${poll.values[i]}% - ${poll.options[i]} -
- #end for - ${poll.votes} votes β€’ ${poll.status} -
-#end proc -# -#proc renderStats(stats: TweetStats): string = -
- πŸ’¬ ${$stats.replies} - πŸ”„ ${$stats.retweets} - πŸ‘ ${$stats.likes} -
-#end proc -# -#proc renderShowThread(tweet: Tweet | Quote): string = -Show this thread -#end proc -# -#proc renderReply(tweet: Tweet | Quote): string = -#let usernames = tweet.reply.mapIt(&"""@{it}""") -
Replying to ${usernames.join(" ")}
-#end proc -# -#proc renderQuote(quote: Quote): string = -#let hasMedia = quote.thumb.len > 0 or quote.sensitive -#if not quote.available: -
-
This tweet is unavailable
-
-#return -#end if -
-
- - #if hasMedia: -
-
- #if quote.thumb.len > 0: - ${genImg(quote.thumb)} - #if quote.badge.len > 0: -
-
${quote.badge}
-
- #end if - #elif quote.sensitive: -
- ❗ -
- #end if -
-
- #end if -
- ${linkUser(quote.profile, class="fullname")} - ${linkUser(quote.profile, class="username")} -
- #if quote.reply.len > 0: - ${renderReply(quote)} - #end if -
${linkifyText(quote.text)}
- #if quote.hasThread: - ${renderShowThread(quote)} - #end if -
-
-#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: -
-#end if -#if tweet.available: -
-
- ${renderHeading(tweet)} - #if first and tweet.reply.len > 0: - ${renderReply(tweet)} - #end if -
-
${linkifyText(tweet.text)}
-
- #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 -
-
-#else: -
-
This tweet is unavailable
-
-#end if -#if divClass.len > 0: -
-#end if -#end proc diff --git a/src/views/user.nimf b/src/views/user.nimf deleted file mode 100644 index 1c50f32..0000000 --- a/src/views/user.nimf +++ /dev/null @@ -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 = -
- - ${genImg(profile.getUserpic("_200x200"))} - -
-
- ${linkUser(profile, class="profile-card-fullname")} - ${linkUser(profile, class="profile-card-username")} -
-
-
- #if profile.bio.len > 0: -
-

${linkifyText(profile.bio)}

-
- #end if - - -
-
-#end proc -# -#proc renderPhotoRail(username: string; photoRail: seq[GalleryPhoto]): string = -
-
- πŸ–Ό Photos and videos -
-
- #for i, photo in photoRail: - #if i == 20: break - #end if - - - - #end for -
-
-#end proc -# -#proc renderBanner(profile: Profile): string = -#if "#" in profile.banner: -
-#else: -#let url = getSigUrl(profile.banner, "pic") -${genImg(profile.banner)} -#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 -
- #if not beginning: -
- Load newest tweets -
- #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: -
- #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 -
- #threads.add tweet.threadId - #end if - #end for - # - #if timeline.hasMore or timeline.query.isSome and timeline.tweets.len > 0: -
- Load older tweets -
- #elif timeline.tweets.len > 0: - - #else: -
- #if profile.protected: -

This account's tweets are protected.

-

Only confirmed followers have access to @${profile.username}'s tweets.

- #else: -

No tweets found.

- #end if -
- #end if -
-#end proc -# -#proc renderProfile*(profile: Profile; timeline: Timeline; -# photoRail: seq[GalleryPhoto]; beginning: bool): string = -
-
- ${renderBanner(profile)} -
-
- ${renderProfileCard(profile)} - #if photoRail.len > 0: - ${renderPhotoRail(profile.username, photoRail)} - #end if -
-
- #let link = "/" & profile.username - - ${renderTimeline(timeline, profile, beginning)} -
-
-#end proc -# -#proc renderConversation*(conversation: Conversation): string = -
-
- #if conversation.before != nil: -
- #for i, tweet in conversation.before.tweets: - ${renderTweet(tweet, first=(i == 0))} - #end for -
- #end if -
- #let afterClass = if conversation.after != nil: "thread thread-line" else: "" - ${renderTweet(conversation.tweet, class=afterClass)} -
- #if conversation.after != nil: -
- #for i, tweet in conversation.after.tweets: - ${renderTweet(tweet, first=(i == 0), last=(i == conversation.after.tweets.high))} - #end for -
- #end if -
- #if conversation.replies.len > 0: -
- #for thread in conversation.replies: -
- #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: "" -
- #let reply = if thread.more == 1: "reply" else: "replies" - ${num}more ${reply} -
- #end if -
- #end for -
- #end if -
- -#end proc diff --git a/tests/base.py b/tests/base.py index 9d84916..6538492 100644 --- a/tests/base.py +++ b/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' diff --git a/tests/test_profile.py b/tests/test_profile.py index bf080f6..8d3ebae 100644 --- a/tests/test_profile.py +++ b/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'], ['%']] diff --git a/tests/test_tweet.py b/tests/test_tweet.py index 6671ecb..c62f1c8 100644 --- a/tests/test_tweet.py +++ b/tests/test_tweet.py @@ -16,7 +16,7 @@ timeline = [ ] status = [ - [20, 'jack 🌍🌏🌎', 'jack', '21 Mar 2006', 'just setting up my twttr'], + [20, 'jack πŸŒπŸŒπŸŒŽβœ”', 'jack', '21 Mar 2006', 'just setting up my twttr'], [134849778302464000, 'The Twoffice', 'TheTwoffice', '10 Nov 2011', 'test'], [105685475985080322, 'The Twoffice', 'TheTwoffice', '22 Aug 2011', 'regular tweet'], [572593440719912960, 'Test account', 'mobile_test', '2 Mar 2015', 'testing test'] @@ -77,7 +77,7 @@ emoji = [ retweet = [ [7, 'mobile_test_2', 'mobile test 2', 'Test account', '@mobile_test', '1234'], - [3, 'mobile_test_8', 'mobile test 8', 'jack 🌍🌏🌎', '@jack', 'twttr'] + [3, 'mobile_test_8', 'mobile test 8', 'jack πŸŒπŸŒπŸŒŽβœ”', '@jack', 'twttr'] ]