tweet.nim 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. # SPDX-License-Identifier: AGPL-3.0-only
  2. import strutils, sequtils, strformat, options, algorithm
  3. import karax/[karaxdsl, vdom, vstyles]
  4. from jester import Request
  5. import renderutils
  6. import ".."/[types, utils, formatters]
  7. import general
  8. const doctype = "<!DOCTYPE html>\n"
  9. proc renderMiniAvatar(user: User; prefs: Prefs): VNode =
  10. genImg(user.getUserPic("_mini"), class=(prefs.getAvatarClass & " mini"))
  11. proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): VNode =
  12. buildHtml(tdiv):
  13. if pinned:
  14. tdiv(class="pinned"):
  15. span: icon "pin", "Pinned Tweet"
  16. elif retweet.len > 0:
  17. tdiv(class="retweet-header"):
  18. span: icon "retweet", retweet & " retweeted"
  19. tdiv(class="tweet-header"):
  20. a(class="tweet-avatar", href=("/" & tweet.user.username)):
  21. var size = "_bigger"
  22. if not prefs.autoplayGifs and tweet.user.userPic.endsWith("gif"):
  23. size = "_400x400"
  24. genImg(tweet.user.getUserPic(size), class=prefs.getAvatarClass)
  25. tdiv(class="tweet-name-row"):
  26. tdiv(class="fullname-and-username"):
  27. linkUser(tweet.user, class="fullname")
  28. verifiedIcon(tweet.user)
  29. linkUser(tweet.user, class="username")
  30. span(class="tweet-date"):
  31. a(href=getLink(tweet), title=tweet.getTime):
  32. text tweet.getShortTime
  33. proc renderAlbum(tweet: Tweet): VNode =
  34. let
  35. groups = if tweet.photos.len < 3: @[tweet.photos]
  36. else: tweet.photos.distribute(2)
  37. buildHtml(tdiv(class="attachments")):
  38. for i, photos in groups:
  39. let margin = if i > 0: ".25em" else: ""
  40. tdiv(class="gallery-row", style={marginTop: margin}):
  41. for photo in photos:
  42. tdiv(class="attachment image"):
  43. let
  44. named = "name=" in photo.url
  45. small = if named: photo.url else: photo.url & smallWebp
  46. a(href=getOrigPicUrl(photo.url), class="still-image", target="_blank"):
  47. genImg(small, alt=photo.altText)
  48. if photo.altText.len > 0:
  49. p(class="alt-text"): text "ALT " & photo.altText
  50. proc isPlaybackEnabled(prefs: Prefs; playbackType: VideoType): bool =
  51. case playbackType
  52. of mp4: prefs.mp4Playback
  53. of m3u8, vmap: prefs.hlsPlayback
  54. proc hasMp4Url(video: Video): bool =
  55. video.variants.anyIt(it.contentType == mp4)
  56. proc renderVideoDisabled(playbackType: VideoType; path: string): VNode =
  57. buildHtml(tdiv(class="video-overlay")):
  58. case playbackType
  59. of mp4:
  60. p: text "mp4 playback disabled in preferences"
  61. of m3u8, vmap:
  62. buttonReferer "/enablehls", "Enable hls playback", path
  63. proc renderVideoUnavailable(video: Video): VNode =
  64. buildHtml(tdiv(class="video-overlay")):
  65. case video.reason
  66. of "dmcaed":
  67. p: text "This media has been disabled in response to a report by the copyright owner"
  68. else:
  69. p: text "This media is unavailable"
  70. proc renderVideo*(video: Video; prefs: Prefs; path: string): VNode =
  71. let
  72. container = if video.description.len == 0 and video.title.len == 0: ""
  73. else: " card-container"
  74. playbackType = if not prefs.proxyVideos and video.hasMp4Url: mp4
  75. else: video.playbackType
  76. buildHtml(tdiv(class="attachments card")):
  77. tdiv(class="gallery-video" & container):
  78. tdiv(class="attachment video-container"):
  79. let thumb = getSmallPic(video.thumb)
  80. if not video.available:
  81. img(src=thumb, loading="lazy")
  82. renderVideoUnavailable(video)
  83. elif not prefs.isPlaybackEnabled(playbackType):
  84. img(src=thumb, loading="lazy")
  85. renderVideoDisabled(playbackType, path)
  86. else:
  87. let
  88. vars = video.variants.filterIt(it.contentType == playbackType)
  89. vidUrl = vars.sortedByIt(it.resolution)[^1].url
  90. source = if prefs.proxyVideos: getVidUrl(vidUrl)
  91. else: vidUrl
  92. case playbackType
  93. of mp4:
  94. video(poster=thumb, controls="", muted=prefs.muteVideos):
  95. source(src=source, `type`="video/mp4")
  96. of m3u8, vmap:
  97. video(poster=thumb, data-url=source, data-autoload="false", muted=prefs.muteVideos)
  98. verbatim "<div class=\"video-overlay\" onclick=\"playVideo(this)\">"
  99. tdiv(class="overlay-circle"): span(class="overlay-triangle")
  100. tdiv(class="overlay-duration"): text getDuration(video)
  101. verbatim "</div>"
  102. if container.len > 0:
  103. tdiv(class="card-content"):
  104. h2(class="card-title"): text video.title
  105. if video.description.len > 0:
  106. p(class="card-description"): text video.description
  107. proc renderGif(gif: Gif; prefs: Prefs): VNode =
  108. buildHtml(tdiv(class="attachments media-gif")):
  109. tdiv(class="gallery-gif", style={maxHeight: "unset"}):
  110. tdiv(class="attachment"):
  111. video(class="gif", poster=getSmallPic(gif.thumb), autoplay=prefs.autoplayGifs,
  112. controls="", muted="", loop=""):
  113. source(src=getPicUrl(gif.url), `type`="video/mp4")
  114. proc renderPoll(poll: Poll): VNode =
  115. buildHtml(tdiv(class="poll")):
  116. for i in 0 ..< poll.options.len:
  117. let
  118. leader = if poll.leader == i: " leader" else: ""
  119. val = poll.values[i]
  120. perc = if val > 0: val / poll.votes * 100 else: 0
  121. percStr = (&"{perc:>3.0f}").strip(chars={'.'}) & '%'
  122. tdiv(class=("poll-meter" & leader)):
  123. span(class="poll-choice-bar", style={width: percStr})
  124. span(class="poll-choice-value"): text percStr
  125. span(class="poll-choice-option"): text poll.options[i]
  126. span(class="poll-info"):
  127. text &"{insertSep($poll.votes, ',')} votes • {poll.status}"
  128. proc renderCardImage(card: Card): VNode =
  129. buildHtml(tdiv(class="card-image-container")):
  130. tdiv(class="card-image"):
  131. genImg(card.image)
  132. if card.kind == player:
  133. tdiv(class="card-overlay"):
  134. tdiv(class="overlay-circle"):
  135. span(class="overlay-triangle")
  136. proc renderCardContent(card: Card): VNode =
  137. buildHtml(tdiv(class="card-content")):
  138. h2(class="card-title"): text card.title
  139. if card.text.len > 0:
  140. p(class="card-description"): text card.text
  141. if card.dest.len > 0:
  142. span(class="card-destination"): text card.dest
  143. proc renderCard(card: Card; prefs: Prefs; path: string): VNode =
  144. const smallCards = {app, player, summary, storeLink}
  145. let large = if card.kind notin smallCards: " large" else: ""
  146. let url = replaceUrls(card.url, prefs)
  147. buildHtml(tdiv(class=("card" & large))):
  148. if card.video.isSome:
  149. tdiv(class="card-container"):
  150. renderVideo(get(card.video), prefs, path)
  151. a(class="card-content-container", href=url):
  152. renderCardContent(card)
  153. else:
  154. a(class="card-container", href=url):
  155. if card.image.len > 0:
  156. renderCardImage(card)
  157. tdiv(class="card-content-container"):
  158. renderCardContent(card)
  159. func formatStat(stat: int): string =
  160. if stat > 0: insertSep($stat, ',')
  161. else: ""
  162. proc renderStats(stats: TweetStats): VNode =
  163. buildHtml(tdiv(class="tweet-stats")):
  164. span(class="tweet-stat"): icon "comment", formatStat(stats.replies)
  165. span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets)
  166. span(class="tweet-stat"): icon "heart", formatStat(stats.likes)
  167. span(class="tweet-stat"): icon "views", formatStat(stats.views)
  168. proc renderReply(tweet: Tweet): VNode =
  169. buildHtml(tdiv(class="replying-to")):
  170. text "Replying to "
  171. for i, u in tweet.reply:
  172. if i > 0: text " "
  173. a(href=("/" & u)): text "@" & u
  174. proc renderAttribution(user: User; prefs: Prefs): VNode =
  175. buildHtml(a(class="attribution", href=("/" & user.username))):
  176. renderMiniAvatar(user, prefs)
  177. strong: text user.fullname
  178. verifiedIcon(user)
  179. proc renderMediaTags(tags: seq[User]): VNode =
  180. buildHtml(tdiv(class="media-tag-block")):
  181. icon "user"
  182. for i, p in tags:
  183. a(class="media-tag", href=("/" & p.username), title=p.username):
  184. text p.fullname
  185. if i < tags.high:
  186. text ", "
  187. proc renderQuoteMedia(quote: Tweet; prefs: Prefs; path: string): VNode =
  188. buildHtml(tdiv(class="quote-media-container")):
  189. if quote.photos.len > 0:
  190. renderAlbum(quote)
  191. elif quote.video.isSome:
  192. renderVideo(quote.video.get(), prefs, path)
  193. elif quote.gif.isSome:
  194. renderGif(quote.gif.get(), prefs)
  195. proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode =
  196. if not quote.available:
  197. return buildHtml(tdiv(class="quote unavailable")):
  198. a(class="unavailable-quote", href=getLink(quote, focus=false)):
  199. if quote.tombstone.len > 0:
  200. text quote.tombstone
  201. elif quote.text.len > 0:
  202. text quote.text
  203. else:
  204. text "This tweet is unavailable"
  205. buildHtml(tdiv(class="quote quote-big")):
  206. a(class="quote-link", href=getLink(quote))
  207. tdiv(class="tweet-name-row"):
  208. tdiv(class="fullname-and-username"):
  209. renderMiniAvatar(quote.user, prefs)
  210. linkUser(quote.user, class="fullname")
  211. verifiedIcon(quote.user)
  212. linkUser(quote.user, class="username")
  213. span(class="tweet-date"):
  214. a(href=getLink(quote), title=quote.getTime):
  215. text quote.getShortTime
  216. if quote.reply.len > 0:
  217. renderReply(quote)
  218. if quote.text.len > 0:
  219. tdiv(class="quote-text", dir="auto"):
  220. verbatim replaceUrls(quote.text, prefs)
  221. if quote.hasThread:
  222. a(class="show-thread", href=getLink(quote)):
  223. text "Show this thread"
  224. if quote.photos.len > 0 or quote.video.isSome or quote.gif.isSome:
  225. renderQuoteMedia(quote, prefs, path)
  226. proc renderLocation*(tweet: Tweet): string =
  227. let (place, url) = tweet.getLocation()
  228. if place.len == 0: return
  229. let node = buildHtml(span(class="tweet-geo")):
  230. text " – at "
  231. if url.len > 1:
  232. a(href=url): text place
  233. else:
  234. text place
  235. return $node
  236. proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
  237. last=false; mainTweet=false; afterTweet=false): VNode =
  238. var divClass = class
  239. if index == -1 or last:
  240. divClass = "thread-last " & class
  241. if not tweet.available:
  242. return buildHtml(tdiv(class=divClass & "unavailable timeline-item", data-username=tweet.user.username)):
  243. a(class="unavailable-box", href=getLink(tweet)):
  244. if tweet.tombstone.len > 0:
  245. text tweet.tombstone
  246. elif tweet.text.len > 0:
  247. text tweet.text
  248. else:
  249. text "This tweet is unavailable"
  250. if tweet.quote.isSome:
  251. renderQuote(tweet.quote.get(), prefs, path)
  252. let
  253. fullTweet = tweet
  254. pinned = tweet.pinned
  255. var retweet: string
  256. var tweet = fullTweet
  257. if tweet.retweet.isSome:
  258. tweet = tweet.retweet.get
  259. retweet = fullTweet.user.fullname
  260. buildHtml(tdiv(class=("timeline-item " & divClass), data-username=tweet.user.username)):
  261. if not mainTweet:
  262. a(class="tweet-link", href=getLink(tweet))
  263. tdiv(class="tweet-body"):
  264. renderHeader(tweet, retweet, pinned, prefs)
  265. if not afterTweet and index == 0 and tweet.reply.len > 0 and
  266. (tweet.reply.len > 1 or tweet.reply[0] != tweet.user.username or pinned):
  267. renderReply(tweet)
  268. var tweetClass = "tweet-content media-body"
  269. if prefs.bidiSupport:
  270. tweetClass &= " tweet-bidi"
  271. tdiv(class=tweetClass, dir="auto"):
  272. verbatim replaceUrls(tweet.text, prefs) & renderLocation(tweet)
  273. if tweet.attribution.isSome:
  274. renderAttribution(tweet.attribution.get(), prefs)
  275. if tweet.card.isSome and tweet.card.get().kind != hidden:
  276. renderCard(tweet.card.get(), prefs, path)
  277. if tweet.photos.len > 0:
  278. renderAlbum(tweet)
  279. elif tweet.video.isSome:
  280. renderVideo(tweet.video.get(), prefs, path)
  281. elif tweet.gif.isSome:
  282. renderGif(tweet.gif.get(), prefs)
  283. if tweet.poll.isSome:
  284. renderPoll(tweet.poll.get())
  285. if tweet.quote.isSome:
  286. renderQuote(tweet.quote.get(), prefs, path)
  287. if mainTweet:
  288. p(class="tweet-published"): text &"{getTime(tweet)}"
  289. if tweet.mediaTags.len > 0:
  290. renderMediaTags(tweet.mediaTags)
  291. if not prefs.hideTweetStats:
  292. renderStats(tweet.stats)
  293. proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; req: Request): string =
  294. let node = buildHtml(html(lang="en")):
  295. renderHead(prefs, cfg, req)
  296. body:
  297. tdiv(class="tweet-embed"):
  298. renderTweet(tweet, prefs, path, mainTweet=true)
  299. result = doctype & $node