mirror of
https://github.com/zedeus/nitter
synced 2026-09-05 14:49:32 +00:00
465 lines
16 KiB
Nim
465 lines
16 KiB
Nim
# SPDX-License-Identifier: AGPL-3.0-only
|
||
import strutils, sequtils, strformat, options, algorithm
|
||
import karax/[karaxdsl, vdom, vstyles]
|
||
from jester import Request
|
||
|
||
import renderutils
|
||
import ".."/[types, utils, formatters]
|
||
import general
|
||
|
||
const doctype = "<!DOCTYPE html>\n"
|
||
|
||
proc renderMiniAvatar*(user: User; prefs: Prefs): VNode =
|
||
genImg(user.getUserPic("_mini"), class=(prefs.getAvatarClass & " mini"))
|
||
|
||
proc renderArticleCard(preview: ArticlePreview; prefs: Prefs): VNode =
|
||
let url = "/i/article/" & $preview.tweetId
|
||
buildHtml(tdiv(class="article-card card large")):
|
||
a(class="card-container", href=url):
|
||
if preview.coverImage.len > 0:
|
||
tdiv(class="card-image-container"):
|
||
tdiv(class="card-image"):
|
||
genImg(preview.coverImage)
|
||
span(class="article-card-badge"): text "Article"
|
||
tdiv(class="card-content-container"):
|
||
tdiv(class="card-content"):
|
||
h2(class="card-title"): text preview.title
|
||
if preview.previewText.len > 0:
|
||
p(class="card-description"): text preview.previewText
|
||
|
||
proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs;
|
||
path = ""): VNode =
|
||
buildHtml(tdiv):
|
||
if pinned:
|
||
let pinnedLabel =
|
||
if "/i/communities/" in path: "Pinned by Community mods"
|
||
else: "Pinned Tweet"
|
||
tdiv(class="pinned"):
|
||
span: icon("pin", pinnedLabel)
|
||
elif retweet.len > 0:
|
||
tdiv(class="retweet-header"):
|
||
span: icon("retweet", retweet & " retweeted")
|
||
|
||
tdiv(class="tweet-header"):
|
||
a(class="tweet-avatar", href=("/" & tweet.user.username)):
|
||
var size = "_bigger"
|
||
if not prefs.autoplayGifs and tweet.user.userPic.endsWith("gif"):
|
||
size = "_400x400"
|
||
genImg(tweet.user.getUserPic(size), class=prefs.getAvatarClass)
|
||
|
||
tdiv(class="tweet-name-row"):
|
||
tdiv(class="fullname-and-username"):
|
||
linkUser(tweet.user, class="fullname")
|
||
verifiedIcon(tweet.user)
|
||
linkUser(tweet.user, class="username")
|
||
|
||
span(class="tweet-date"):
|
||
a(href=getLink(tweet), title=tweet.getTime):
|
||
text tweet.getShortTime
|
||
|
||
proc renderAltText(altText: string): VNode =
|
||
buildHtml(p(class="alt-text")):
|
||
text "ALT " & altText
|
||
|
||
proc renderPhotoAttachment(photo: Photo; bigThumb=false): VNode =
|
||
buildHtml(tdiv(class="attachment")):
|
||
let
|
||
named = "name=" in photo.url
|
||
thumb = if named: photo.url
|
||
elif bigThumb: photo.url & mediumWebp
|
||
else: photo.url & smallWebp
|
||
a(href=getOrigPicUrl(photo.url), class="still-image", target="_blank"):
|
||
genImg(thumb, alt=photo.altText)
|
||
if photo.altText.len > 0:
|
||
renderAltText(photo.altText)
|
||
|
||
proc isPlaybackEnabled(prefs: Prefs; playbackType: VideoType): bool =
|
||
case playbackType
|
||
of mp4: prefs.mp4Playback
|
||
of m3u8, vmap: prefs.hlsPlayback
|
||
|
||
proc hasMp4Url(video: Video): bool =
|
||
video.variants.anyIt(it.contentType == mp4)
|
||
|
||
proc renderVideoDisabled(playbackType: VideoType; path=""): VNode =
|
||
buildHtml(tdiv(class="video-overlay")):
|
||
case playbackType
|
||
of mp4:
|
||
p: text "mp4 playback disabled in preferences"
|
||
of m3u8, vmap:
|
||
buttonReferer "/enablehls", "Enable hls playback", path
|
||
|
||
proc renderVideoUnavailable(video: Video): VNode =
|
||
buildHtml(tdiv(class="video-overlay")):
|
||
case video.reason
|
||
of "dmcaed":
|
||
p: text "This media has been disabled in response to a report by the copyright owner"
|
||
else:
|
||
p: text "This media is unavailable"
|
||
|
||
proc renderVideoAttachment(videoData: Video; prefs: Prefs; path=""; bigThumb=false): VNode =
|
||
let
|
||
playbackType = if not prefs.proxyVideos and videoData.hasMp4Url: mp4
|
||
else: videoData.playbackType
|
||
thumb = if bigThumb: getMediumPic(videoData.thumb) else: getSmallPic(videoData.thumb)
|
||
|
||
buildHtml(tdiv(class="attachment")):
|
||
if not videoData.available:
|
||
img(src=thumb, loading="lazy")
|
||
renderVideoUnavailable(videoData)
|
||
elif not prefs.isPlaybackEnabled(playbackType):
|
||
img(src=thumb, loading="lazy")
|
||
renderVideoDisabled(playbackType, path)
|
||
else:
|
||
let
|
||
vars = videoData.variants.filterIt(it.contentType == playbackType)
|
||
vidUrl = vars.sortedByIt(it.resolution)[^1].url
|
||
source = if prefs.proxyVideos and vidUrl.startsWith("http"):
|
||
getVidUrl(vidUrl) else: vidUrl
|
||
case playbackType
|
||
of mp4:
|
||
video(poster=thumb, controls="", muted=prefs.muteVideos):
|
||
source(src=source, `type`="video/mp4")
|
||
of m3u8, vmap:
|
||
video(poster=thumb, data-url=source, data-autoload="false", muted=prefs.muteVideos)
|
||
verbatim "<div class=\"video-overlay\" onclick=\"playVideo(this)\">"
|
||
tdiv(class="overlay-circle"): span(class="overlay-triangle")
|
||
if videoData.durationMs > 0:
|
||
tdiv(class="overlay-duration"): text getDuration(videoData)
|
||
verbatim "</div>"
|
||
|
||
proc renderVideo*(video: Video; prefs: Prefs; path: string; bigThumb=false): VNode =
|
||
let hasCardContent = video.description.len > 0 or video.title.len > 0
|
||
|
||
buildHtml(tdiv(class="attachments card")):
|
||
tdiv(class=("gallery-video" & (if hasCardContent: " card-container" else: ""))):
|
||
renderVideoAttachment(video, prefs, path, bigThumb)
|
||
if hasCardContent:
|
||
tdiv(class="card-content"):
|
||
h2(class="card-title"): text video.title
|
||
if video.description.len > 0:
|
||
p(class="card-description"): text video.description
|
||
|
||
proc renderGifAttachment(gif: Gif; prefs: Prefs): VNode =
|
||
let thumb = getSmallPic(gif.thumb)
|
||
|
||
buildHtml(tdiv(class="attachment")):
|
||
if not prefs.mp4Playback:
|
||
img(src=thumb, loading="lazy")
|
||
renderVideoDisabled(mp4)
|
||
elif prefs.autoplayGifs:
|
||
video(class="gif", poster=thumb, autoplay="", muted="", loop=""):
|
||
source(src=getPicUrl(gif.url), `type`="video/mp4")
|
||
else:
|
||
video(class="gif", poster=thumb, controls="", muted="", loop=""):
|
||
source(src=getPicUrl(gif.url), `type`="video/mp4")
|
||
if gif.altText.len > 0:
|
||
renderAltText(gif.altText)
|
||
|
||
proc renderGif(gif: Gif; prefs: Prefs): VNode =
|
||
buildHtml(tdiv(class="attachments media-gif")):
|
||
renderGifAttachment(gif, prefs)
|
||
|
||
proc renderMedia(media: seq[Media]; prefs: Prefs; path: string; bigThumb=false): VNode =
|
||
if media.len == 0:
|
||
return nil
|
||
|
||
if media.len == 1:
|
||
let item = media[0]
|
||
if item.kind == videoMedia:
|
||
return renderVideo(item.video, prefs, path, bigThumb)
|
||
if item.kind == gifMedia:
|
||
return renderGif(item.gif, prefs)
|
||
|
||
let
|
||
groups = if media.len < 3: @[media]
|
||
else: media.distribute(2)
|
||
|
||
buildHtml(tdiv(class="attachments")):
|
||
for i, mediaGroup in groups:
|
||
let margin = if i > 0: ".25em" else: ""
|
||
let rowClass = "gallery-row" &
|
||
(if mediaGroup.allIt(it.kind == photoMedia): "" else: " mixed-row")
|
||
tdiv(class=rowClass, style={marginTop: margin}):
|
||
for mediaItem in mediaGroup:
|
||
case mediaItem.kind
|
||
of photoMedia:
|
||
renderPhotoAttachment(mediaItem.photo, bigThumb)
|
||
of videoMedia:
|
||
renderVideoAttachment(mediaItem.video, prefs, path, bigThumb)
|
||
of gifMedia:
|
||
renderGifAttachment(mediaItem.gif, prefs)
|
||
|
||
proc renderPoll(poll: Poll): VNode =
|
||
buildHtml(tdiv(class="poll")):
|
||
for i in 0 ..< poll.options.len:
|
||
let
|
||
leader = if poll.leader == i: " leader" else: ""
|
||
val = poll.values[i]
|
||
perc = if val > 0: val / poll.votes * 100 else: 0
|
||
percStr = (&"{perc:>3.0f}").strip(chars={'.'}) & '%'
|
||
tdiv(class=("poll-meter" & leader)):
|
||
span(class="poll-choice-bar", style={width: percStr})
|
||
span(class="poll-choice-value"): text percStr
|
||
span(class="poll-choice-option"): text poll.options[i]
|
||
span(class="poll-info"):
|
||
text &"{insertSep($poll.votes, ',')} votes • {poll.status}"
|
||
|
||
proc renderCardImage(card: Card): VNode =
|
||
buildHtml(tdiv(class="card-image-container")):
|
||
tdiv(class="card-image"):
|
||
genImg(card.image)
|
||
if card.kind == player:
|
||
tdiv(class="card-overlay"):
|
||
tdiv(class="overlay-circle"):
|
||
span(class="overlay-triangle")
|
||
|
||
proc renderCardContent(card: Card): VNode =
|
||
buildHtml(tdiv(class="card-content")):
|
||
h2(class="card-title"): text card.title
|
||
if card.text.len > 0:
|
||
p(class="card-description"): text card.text
|
||
if card.dest.len > 0:
|
||
span(class="card-destination"): text card.dest
|
||
|
||
proc renderCard(card: Card; prefs: Prefs; path: string): VNode =
|
||
const smallCards = {app, player, summary, storeLink}
|
||
let large = if card.kind notin smallCards: " large" else: ""
|
||
let url = replaceUrls(card.url, prefs)
|
||
|
||
buildHtml(tdiv(class=("card" & large))):
|
||
if card.video.isSome:
|
||
tdiv(class="card-container"):
|
||
renderVideo(get(card.video), prefs, path)
|
||
a(class="card-content-container", href=url):
|
||
renderCardContent(card)
|
||
else:
|
||
a(class="card-container", href=url):
|
||
if card.image.len > 0:
|
||
renderCardImage(card)
|
||
tdiv(class="card-content-container"):
|
||
renderCardContent(card)
|
||
|
||
func formatStat(stat: int): string =
|
||
if stat > 0: insertSep($stat, ',')
|
||
else: ""
|
||
|
||
proc renderStats*(stats: TweetStats): VNode =
|
||
buildHtml(tdiv(class="tweet-stats")):
|
||
span(class="tweet-stat"): icon "comment", formatStat(stats.replies)
|
||
span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets)
|
||
span(class="tweet-stat"): icon "heart", formatStat(stats.likes)
|
||
span(class="tweet-stat"): icon "views", formatStat(stats.views)
|
||
|
||
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 renderAttribution(user: User; prefs: Prefs; link = ""): VNode =
|
||
let href = if link.len > 0: link else: "/" & user.username
|
||
buildHtml(a(class="attribution", href=href)):
|
||
renderMiniAvatar(user, prefs)
|
||
strong: text user.fullname
|
||
verifiedIcon(user)
|
||
|
||
proc renderMediaTags(tags: seq[User]): VNode =
|
||
buildHtml(tdiv(class="media-tag-block")):
|
||
icon "user"
|
||
for i, p in tags:
|
||
a(class="media-tag", href=("/" & p.username), title=p.username):
|
||
text p.fullname
|
||
if i < tags.high:
|
||
text ", "
|
||
|
||
proc renderLatestPost(username: string; id: int64): VNode =
|
||
buildHtml(tdiv(class="latest-post-version")):
|
||
text "There's a new version of this post. "
|
||
a(href=getLink(id, username)):
|
||
text "See the latest post"
|
||
|
||
proc renderCommunityNote(note: string; prefs: Prefs): VNode =
|
||
buildHtml(tdiv(class="community-note")):
|
||
tdiv(class="community-note-header"):
|
||
icon "group"
|
||
span: text "Community note"
|
||
tdiv(class="community-note-text", dir="auto"):
|
||
verbatim replaceUrls(note, prefs)
|
||
|
||
proc renderQuoteMedia(quote: Tweet; prefs: Prefs; path: string): VNode =
|
||
buildHtml(tdiv(class="quote-media-container")):
|
||
renderMedia(quote.media, prefs, path)
|
||
|
||
proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode =
|
||
if not quote.available:
|
||
return buildHtml(tdiv(class="quote unavailable")):
|
||
a(class="unavailable-quote", href=getLink(quote, focus=false)):
|
||
if quote.tombstone.len > 0:
|
||
text quote.tombstone
|
||
elif quote.text.len > 0:
|
||
text quote.text
|
||
else:
|
||
text "This tweet is unavailable"
|
||
|
||
buildHtml(tdiv(class="quote quote-big")):
|
||
a(class="quote-link", href=getLink(quote))
|
||
|
||
tdiv(class="tweet-name-row"):
|
||
tdiv(class="fullname-and-username"):
|
||
renderMiniAvatar(quote.user, prefs)
|
||
linkUser(quote.user, class="fullname")
|
||
verifiedIcon(quote.user)
|
||
linkUser(quote.user, class="username")
|
||
|
||
span(class="tweet-date"):
|
||
a(href=getLink(quote), title=quote.getTime):
|
||
text quote.getShortTime
|
||
|
||
if quote.reply.len > 0:
|
||
renderReply(quote)
|
||
|
||
if quote.text.len > 0:
|
||
tdiv(class="quote-text", dir="auto"):
|
||
verbatim replaceUrls(quote.text, prefs)
|
||
|
||
if quote.media.len > 0:
|
||
renderQuoteMedia(quote, prefs, path)
|
||
|
||
if quote.articlePreview.isSome:
|
||
renderArticleCard(quote.articlePreview.get(), prefs)
|
||
|
||
if quote.note.len > 0 and not prefs.hideCommunityNotes:
|
||
renderCommunityNote(quote.note, prefs)
|
||
|
||
if quote.hasThread:
|
||
a(class="show-thread", href=getLink(quote)):
|
||
text "Show this thread"
|
||
|
||
if quote.history.len > 0 and quote.id != max(quote.history):
|
||
tdiv(class="quote-latest"):
|
||
text "There's a new version of this post"
|
||
|
||
proc renderDisclosures*(tweet: Tweet): VNode =
|
||
buildHtml(tdiv(class="disclosures")):
|
||
if tweet.isAI:
|
||
span(data-disclosure="ai"):
|
||
icon "attention", "Made with AI"
|
||
if tweet.isAd:
|
||
span(data-disclosure="ad"):
|
||
icon "attention", "Paid partnership (ad)"
|
||
|
||
proc renderLocation*(tweet: Tweet): string =
|
||
let (place, url) = tweet.getLocation()
|
||
if place.len == 0: return
|
||
let node = buildHtml(span(class="tweet-geo")):
|
||
text " – at "
|
||
if url.len > 1:
|
||
a(href=url): text place
|
||
else:
|
||
text place
|
||
return $node
|
||
|
||
proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
||
last=false; mainTweet=false; afterTweet=false;
|
||
bigThumb=false): VNode =
|
||
var divClass = class
|
||
if index == -1 or last:
|
||
divClass = "thread-last " & class
|
||
|
||
if not tweet.available:
|
||
return buildHtml(tdiv(class=divClass & "unavailable timeline-item", data-username=tweet.user.username)):
|
||
a(class="unavailable-box", href=getLink(tweet)):
|
||
if tweet.tombstone.len > 0:
|
||
text tweet.tombstone
|
||
elif tweet.text.len > 0:
|
||
text tweet.text
|
||
else:
|
||
text "This tweet is unavailable"
|
||
|
||
if tweet.quote.isSome:
|
||
renderQuote(tweet.quote.get(), prefs, path)
|
||
|
||
let
|
||
fullTweet = tweet
|
||
pinned = tweet.pinned
|
||
|
||
var retweet: string
|
||
var tweet = fullTweet
|
||
if tweet.retweet.isSome:
|
||
tweet = tweet.retweet.get
|
||
retweet = fullTweet.user.fullname
|
||
|
||
buildHtml(tdiv(class=("timeline-item " & divClass), data-username=tweet.user.username)):
|
||
if not mainTweet:
|
||
a(class="tweet-link", href=getLink(tweet))
|
||
|
||
tdiv(class="tweet-body"):
|
||
renderHeader(tweet, retweet, pinned, prefs, path)
|
||
|
||
if not afterTweet and index == 0 and tweet.reply.len > 0 and
|
||
(tweet.reply.len > 1 or tweet.reply[0] != tweet.user.username or pinned):
|
||
renderReply(tweet)
|
||
|
||
var tweetClass = "tweet-content media-body"
|
||
if prefs.bidiSupport:
|
||
tweetClass &= " tweet-bidi"
|
||
|
||
tdiv(class=tweetClass, dir="auto"):
|
||
verbatim replaceUrls(tweet.text, prefs) & renderLocation(tweet)
|
||
|
||
if tweet.attribution.isSome:
|
||
renderAttribution(tweet.attribution.get(), prefs, tweet.attributionLink)
|
||
|
||
if tweet.card.isSome and tweet.card.get().kind != hidden:
|
||
renderCard(tweet.card.get(), prefs, path)
|
||
|
||
if tweet.articlePreview.isSome:
|
||
renderArticleCard(tweet.articlePreview.get(), prefs)
|
||
|
||
if tweet.media.len > 0:
|
||
renderMedia(tweet.media, prefs, path, bigThumb)
|
||
|
||
if tweet.poll.isSome:
|
||
renderPoll(tweet.poll.get())
|
||
|
||
if tweet.quote.isSome:
|
||
renderQuote(tweet.quote.get(), prefs, path)
|
||
|
||
if tweet.note.len > 0 and not prefs.hideCommunityNotes:
|
||
renderCommunityNote(tweet.note, prefs)
|
||
|
||
if tweet.isAI or tweet.isAd:
|
||
renderDisclosures(tweet)
|
||
|
||
let
|
||
hasEdits = tweet.history.len > 1
|
||
isLatest = hasEdits and tweet.id == max(tweet.history)
|
||
|
||
if mainTweet:
|
||
p(class="tweet-published"):
|
||
if hasEdits and isLatest:
|
||
a(href=(getLink(tweet, focus=false) & "/history")):
|
||
text &"Last edited {getTime(tweet)}"
|
||
else:
|
||
text &"{getTime(tweet)}"
|
||
|
||
if hasEdits and not isLatest:
|
||
renderLatestPost(tweet.user.username, max(tweet.history))
|
||
|
||
if tweet.mediaTags.len > 0:
|
||
renderMediaTags(tweet.mediaTags)
|
||
|
||
if not prefs.hideTweetStats:
|
||
renderStats(tweet.stats)
|
||
|
||
proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; req: Request): string =
|
||
let node = buildHtml(html(lang="en")):
|
||
renderHead(prefs, cfg, req)
|
||
|
||
body:
|
||
tdiv(class="tweet-embed"):
|
||
renderTweet(tweet, prefs, path, mainTweet=true)
|
||
|
||
result = doctype & $node
|