tweet.nim 13 KB

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