Merge branch 'zedeus:master' into master

This commit is contained in:
Salastil 2026-07-02 17:53:12 -04:00 • committed by GitHub
commit 106df59291
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
71 changed files with 3652 additions and 481 deletions

248
src/views/article.nim Normal file
View file

@ -0,0 +1,248 @@
# SPDX-License-Identifier: AGPL-3.0-only
import strutils, strformat, tables, unicode, bitops, uri
import karax/[karaxdsl, vdom]
import renderutils, tweet, timeline
import ".."/[types, utils, formatters]
proc renderAtomicParagraph(paragraph: ArticleParagraph; article: Article;
tweets: Table[int64, Tweet]; path: string;
prefs: Prefs): VNode =
if paragraph.entityRanges.len == 0:
return text ""
let er = paragraph.entityRanges[0]
if er.key notin article.entities:
return text ""
let entity = article.entities[er.key]
case entity.kind
of "MEDIA":
buildHtml(tdiv(class="article-media")):
for id in entity.mediaIds:
let media = article.media.getOrDefault(id)
if media.url.len == 0:
continue
case media.kind
of "ApiGif":
video(src=getVidUrl(media.url), controls="", autoplay="", loop="",
muted="")
of "ApiVideo":
video(src=getVidUrl(media.url), controls="")
else:
a(href=getOrigPicUrl(media.url), target="_blank"):
img(src=getSmallPic(media.url), alt=entity.caption, loading="lazy")
if entity.caption.len > 0:
p(class="article-media-caption"): text entity.caption
of "TWEET":
let tweet = tweets.getOrDefault(
try: parseBiggestInt(entity.tweetId)
except ValueError: 0, nil)
if tweet != nil:
renderTweet(tweet, prefs, path)
else:
text ""
of "MARKDOWN":
var content = entity.markdown
if content.startsWith("```"):
let firstNl = content.find('\n')
if firstNl >= 0: content = content[firstNl + 1 .. ^1]
if content.endsWith("```"): content = content[0 .. ^4]
content = content.strip
buildHtml(pre()):
code(): text content
of "DIVIDER":
buildHtml(hr(class="article-divider"))
else:
text ""
proc wrapStyle(node: VNode; style: int): VNode =
result = node
if style.testBit(4): result = buildHtml(code()): result
if style.testBit(0): result = buildHtml(strong()): result
if style.testBit(1): result = buildHtml(em()): result
if style.testBit(2): result = buildHtml(del()): result
if style.testBit(3): result = buildHtml(underlined()): result
proc addContent(target: VNode; content: string; style = 0) =
var first = true
for line in content.split('\n'):
if not first:
target.add VNode(kind: VNodeKind.br)
first = false
var pos = 0
while pos < line.len:
let atPos = line.find('@', pos)
if atPos == -1:
target.add wrapStyle(text line[pos .. ^1], style)
break
if atPos > 0 and line[atPos - 1] in Letters + Digits + {'_'}:
target.add wrapStyle(text line[pos .. atPos], style)
pos = atPos + 1
continue
var j = atPos + 1
while j < line.len and j - atPos - 1 < 15 and
line[j] in Letters + Digits + {'_'}:
inc j
if j == atPos + 1:
target.add wrapStyle(text line[pos .. atPos], style)
pos = atPos + 1
continue
if atPos > pos:
target.add wrapStyle(text line[pos ..< atPos], style)
let username = line[atPos + 1 ..< j]
let link = a.newVNode()
link.setAttr("href", "/" & username)
link.add wrapStyle(text ("@" & username), style)
target.add link
pos = j
proc applyInlineStyles(target: VNode; runes: seq[Rune]; start, length: int;
styles: seq[ArticleStyle]) =
if styles.len == 0:
target.addContent($runes[start ..< start + length])
return
var
lastStyle = 0
lastStart = start
let endPos = start + length
for i in start ..< endPos:
var style = 0
for sr in styles:
let
sStart = sr.offset
sEnd = sStart + sr.length
if sStart <= i and sEnd > i:
case sr.style
of "Bold": style.setBit(0)
of "Italic": style.setBit(1)
of "Strikethrough": style.setBit(2)
of "Underline": style.setBit(3)
of "Code": style.setBit(4)
else: discard
if style != lastStyle:
if i > lastStart:
addContent(target, $runes[lastStart ..< i], lastStyle)
lastStyle = style
lastStart = i
if lastStart < endPos:
addContent(target, $runes[lastStart ..< endPos], lastStyle)
proc renderTextParagraph(paragraph: ArticleParagraph; article: Article): VNode =
let text = paragraph.text
result = case paragraph.kind
of "header-one": h1.newVNode()
of "header-two": h2.newVNode()
of "header-three": h3.newVNode()
of "ordered-list-item", "unordered-list-item": li.newVNode()
of "blockquote": VNode(kind: VNodeKind.blockquote)
of "code-block":
let pre = pre.newVNode()
let code = code.newVNode()
code.add text text
pre.add code
return pre
else: p.newVNode()
let
runes = text.toRunes
textLen = runes.len
var last = 0
for er in paragraph.entityRanges:
if er.offset > last:
applyInlineStyles(result, runes, last, er.offset - last,
paragraph.inlineStyles)
last = er.offset + er.length
var target = result
if er.key in article.entities:
let entity = article.entities[er.key]
if entity.kind == "LINK":
let parsed = parseUri(entity.url)
if parsed.scheme in ["http", "https"]:
target = a.newVNode()
if parsed.isTwitterUrl:
target.setAttr("href", parsed.path)
else:
target.setAttr("href", entity.url)
applyInlineStyles(target, runes, er.offset, er.length,
paragraph.inlineStyles)
if target != result:
result.add target
if last < textLen:
applyInlineStyles(result, runes, last, textLen - last,
paragraph.inlineStyles)
if paragraph.kind == "blockquote" and result.len > 0:
let lastChild = result[result.len - 1]
if lastChild.kind == VNodeKind.strong and lastChild.len > 0 and
lastChild[0].kind == VNodeKind.text:
lastChild.setAttr("class", "blockquote-attribution")
proc renderArticle*(article: Article; tweets: Table[int64, Tweet];
path: string; prefs: Prefs; tweetId=""): VNode =
let author = article.user
let main = buildHtml(article(class="article-body")):
h1(class="article-title"): text article.title
tdiv(class="article-author"):
tdiv(class="article-author-row"):
a(class="article-avatar", href=("/" & author.username)):
genImg(author.getUserPic("_bigger"), class=prefs.getAvatarClass)
tdiv(class="article-author-info"):
tdiv(class="article-author-name"):
linkUser(author, class="fullname")
verifiedIcon(author)
tdiv(class="article-author-meta"):
linkUser(author, class="username")
span(class="article-date-sep"): text " · "
a(class="article-date",
href=("/" & author.username & "/status/" & tweetId)):
text article.time.getShortTime
if not prefs.hideTweetStats:
renderStats(article.stats)
var listKind = ""
var list: VNode = nil
for paragraph in article.paragraphs:
let isListItem = paragraph.kind in [
"ordered-list-item", "unordered-list-item"]
if not isListItem and list != nil:
main.add list
list = nil
listKind = ""
if paragraph.kind == "atomic":
main.add renderAtomicParagraph(paragraph, article, tweets, path, prefs)
elif isListItem:
if paragraph.kind != listKind:
if list != nil:
main.add list
list = if paragraph.kind == "ordered-list-item": ol.newVNode()
else: ul.newVNode()
listKind = paragraph.kind
list.add renderTextParagraph(paragraph, article)
else:
main.add renderTextParagraph(paragraph, article)
if list != nil:
main.add list
buildHtml(tdiv(class="article-page")):
if article.coverImage.len > 0:
a(href=getOrigPicUrl(article.coverImage), target="_blank"):
img(class="article-cover", src=getSmallPic(article.coverImage), alt="")
main
renderToTop()

128
src/views/community.nim Normal file
View file

@ -0,0 +1,128 @@
# SPDX-License-Identifier: AGPL-3.0-only
import strutils, strformat, times
import karax/[karaxdsl, vdom]
import renderutils
import ".."/[types, utils, formatters]
proc renderCommunityTabs*(kind: QueryKind; community: Community): VNode =
let
path = &"/i/communities/{community.id}"
q = Query(kind: kind)
buildHtml(tdiv):
ul(class="tab"):
li(class=q.getTabClass(posts)):
a(href=path): text "Top"
li(class=q.getTabClass(replies)):
a(href=(path & "/latest")): text "Latest"
li(class=q.getTabClass(media)):
a(href=(path & "/media")): text "Media"
li(class=q.getTabClass(userList)):
a(href=(path & "/about")): text "About"
if community.hashtags.len > 0:
tdiv(class="community-tags"):
for tag in community.hashtags:
let bare = tag.strip(chars={'#'})
a(class="community-tag",
href=(&"/i/communities/{community.id}/hashtag/{bare}")):
text tag
proc renderMemberTabs*(community: Community; isModerators: bool): VNode =
let path = &"/i/communities/{community.id}"
buildHtml(ul(class="tab")):
li(class=(if not isModerators: "tab-item active" else: "tab-item")):
a(href=(path & "/members")): text "All"
li(class=(if isModerators: "tab-item active" else: "tab-item")):
a(href=(path & "/moderators")): text "Moderators"
proc renderHashtagHeader*(community: Community; tag: string): VNode =
buildHtml(tdiv(class="community-hashtag-header")):
h2(class="community-hashtag-title"): text "#" & tag
proc renderCommunityAbout*(community: Community; moderators: seq[User]): VNode =
buildHtml(tdiv(class="community-about")):
tdiv(class="community-info"):
h2: text "Community Info"
tdiv(class="community-info-item"):
icon "group"
if community.joinPolicy == "Open":
text "Anyone can join this Community."
else:
text "Membership is by approval only."
tdiv(class="community-info-item"):
icon "info"
text "All Communities are publicly visible."
tdiv(class="community-info-item"):
icon "calendar"
let
date = community.createdAt.format("MMMM d, yyyy")
creator = community.creator.username
span:
text &"Created {date} by "
a(href=(&"/{creator}")): text &"@{creator}"
if community.creator.verifiedType != none:
verifiedIcon(community.creator)
if community.rules.len > 0:
tdiv(class="community-rules"):
h2: text "Rules"
p(class="community-rules-intro"):
text "These are set and enforced by Community admins and are in addition to "
a(href="https://help.x.com/rules-and-policies/x-rules"): text "X's rules"
text "."
for i, rule in community.rules:
tdiv(class="community-rule"):
span(class="community-rule-number"): text $(i + 1)
tdiv(class="community-rule-content"):
strong: text rule.name
if rule.description.len > 0:
p: text rule.description
if moderators.len > 0:
tdiv(class="community-moderators"):
h2:
text "Moderators"
a(class="community-mods-link",
href=(&"/i/communities/{community.id}/moderators")):
text "See all"
for user in moderators:
tdiv(class="community-moderator"):
a(href=(&"/{user.username}")):
genImg(user.getUserPic("_bigger"), class="community-mod-avatar")
tdiv(class="community-mod-info"):
a(href=(&"/{user.username}"), class="community-mod-name"):
text user.fullname
if user.verifiedType != none:
verifiedIcon(user)
a(href=(&"/{user.username}"), class="community-mod-username"):
text &"@{user.username}"
proc renderCommunity*(body, nav: VNode; community: Community): VNode =
buildHtml(tdiv(class="timeline-container")):
if community.banner.len > 0:
tdiv(class="timeline-banner"):
a(href=getPicUrl(community.banner), target="_blank"):
genImg(community.banner)
tdiv(class="community-header"):
h1(class="community-name"):
a(href=(&"/i/communities/{community.id}")): text community.name
if community.category.len > 0:
span(class="community-category"): text community.category
if community.description.len > 0:
tdiv(class="community-description"):
text community.description
tdiv(class="community-stats"):
a(class="community-member-count",
href=(&"/i/communities/{community.id}/members")):
text insertSep($community.memberCount, ',')
text " Members"
nav
body

View file

@ -50,8 +50,8 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
buildHtml(head):
link(rel="stylesheet", type="text/css", href="/css/style.css?v=35")
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=5")
link(rel="stylesheet", type="text/css", href="/css/style.css?v=45")
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=7")
if theme.len > 0:
link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css"))
@ -72,7 +72,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
if prefs.hlsPlayback:
script(src="/js/hls.min.js", `defer`="")
script(src="/js/hlsPlayback.js", `defer`="")
script(src="/js/hlsPlayback.js?v=1", `defer`="")
if prefs.infiniteScroll:
script(src="/js/infiniteScroll.js", `defer`="")
@ -84,6 +84,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
text cfg.title
meta(name="viewport", content="width=device-width, initial-scale=1.0")
meta(name="referrer", content="same-origin")
meta(name="theme-color", content="#1F1F1F")
meta(property="og:type", content=ogType)
meta(property="og:title", content=(if ogTitle.len > 0: ogTitle else: titleText))
@ -96,6 +97,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
link(rel="preload", type="image/png", href=bannerUrl, `as`="image")
for url in images:
if url.len == 0: continue
let preloadUrl = if "400x400" in url: getPicUrl(url)
else: getSmallPic(url)
link(rel="preload", type="image/png", href=preloadUrl, `as`="image")
@ -117,13 +119,16 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
# this is last so images are also preloaded
# if this is done earlier, Chrome only preloads one image for some reason
link(rel="preload", type="font/woff2", `as`="font",
href="/fonts/fontello.woff2?61663884", crossorigin="anonymous")
href="/fonts/fontello.woff2?59696369", crossorigin="anonymous")
proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs;
titleText=""; desc=""; ogTitle=""; rss=""; video="";
images: seq[string] = @[]; banner=""): string =
images: seq[string] = @[]; banner="";
twitterLink=""): string =
let twitterLink = getTwitterLink(req.path, req.params)
let twitterLink =
if twitterLink.len > 0: twitterLink
else: getTwitterLink(req.path, req.params)
let node = buildHtml(html(lang="en")):
renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle,

View file

@ -2,7 +2,7 @@
import strutils, strformat
import karax/[karaxdsl, vdom, vstyles]
import renderutils, search
import renderutils, search, timeline
import ".."/[types, utils, formatters]
proc renderStat(num: int; class: string; text=""): VNode =
@ -12,6 +12,13 @@ proc renderStat(num: int; class: string; text=""): VNode =
span(class="profile-stat-num"):
text insertSep($num, ',')
proc renderStatLink(num: int; class, href: string): VNode =
buildHtml(li(class=class)):
a(href=href):
span(class="profile-stat-header"): text capitalizeAscii(class)
span(class="profile-stat-num"):
text insertSep($num, ',')
proc renderUserCard*(user: User; prefs: Prefs; info: AccountInfo): VNode =
buildHtml(tdiv(class="profile-card")):
tdiv(class="profile-card-info"):
@ -65,8 +72,8 @@ proc renderUserCard*(user: User; prefs: Prefs; info: AccountInfo): VNode =
tdiv(class="profile-card-extra-links"):
ul(class="profile-statlist"):
renderStat(user.tweets, "posts", text="Tweets")
renderStat(user.following, "following")
renderStat(user.followers, "followers")
renderStatLink(user.following, "following", &"/{user.username}/following")
renderStatLink(user.followers, "followers", &"/{user.username}/followers")
renderStat(user.likes, "likes")
proc renderPhotoRail(profile: Profile): VNode =
@ -99,7 +106,7 @@ proc renderBanner(banner: string): VNode =
else:
a(href=getPicUrl(banner), target="_blank"): genImg(banner)
proc renderProtected(username: string): VNode =
proc renderProtected*(username: string): VNode =
buildHtml(tdiv(class="timeline-container")):
tdiv(class="timeline-header timeline-protected"):
h2: text "This account's tweets are protected."
@ -128,3 +135,31 @@ proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode =
renderProtected(profile.user.username)
else:
renderTweetSearch(profile.tweets, prefs, path, profile.pinned)
proc renderFollowTabs(user: User; activeTab: string): VNode =
buildHtml(ul(class="tab")):
for tab in ["Following", "Followers"]:
li(class=(if activeTab == tab: "tab-item active" else: "tab-item")):
a(href=(&"/{user.username}/{tab.toLowerAscii()}")): text tab
proc renderUserList*(user: User; results: Result[User]; prefs: Prefs;
path, activeTab: string): VNode =
# Check if we've loaded all results (for page 1)
var displayResults = results
if results.beginning:
let expectedCount = if activeTab == "Followers": user.followers else: user.following
if results.content.len >= expectedCount:
displayResults.bottom = ""
buildHtml(tdiv(class="profile-tabs")):
if not prefs.hideBanner:
tdiv(class="profile-banner"):
renderBanner(user.banner)
let sticky = if prefs.stickyProfile: " sticky" else: ""
tdiv(class=("profile-tab" & sticky)):
renderUserCard(user, prefs, AccountInfo())
tdiv(class="timeline-container"):
renderFollowTabs(user, activeTab)
renderTimelineUsers(displayResults, prefs, path)

View file

@ -7,18 +7,20 @@ const smallWebp* = "?name=small&format=webp"
const mediumWebp* = "?name=medium&format=webp"
proc getSmallPic*(url: string): string =
if url.len == 0: return
result = url
if "?" notin url and not url.endsWith("placeholder.png"):
result &= smallWebp
result = getPicUrl(result)
proc getMediumPic*(url: string): string =
if url.len == 0: return
result = url
if "?" notin url and not url.endsWith("placeholder.png"):
result &= mediumWebp
result = getPicUrl(result)
proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode =
proc icon*(icon: string; label=""; title=""; class=""; href=""): VNode =
var c = "icon-" & icon
if class.len > 0: c = &"{c} {class}"
buildHtml(tdiv(class="icon-container")):
@ -27,8 +29,8 @@ proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode =
else:
span(class=c, title=title)
if text.len > 0:
text " " & text
if label.len > 0:
text " " & label
template verifiedIcon*(user: User): untyped {.dirty.} =
if user.verifiedType != VerifiedType.none:

View file

@ -12,7 +12,7 @@
#elif retweet.len > 0: prefix = &"RT by @{retweet}: "
#elif tweet.reply.len > 0: prefix = &"R to @{tweet.reply[0]}: "
#end if
#var text = stripHtml(tweet.text)
#var text = strutils.splitWhitespace(stripHtml(tweet.text)).join(" ")
##if unicode.runeLen(text) > 32:
## text = unicode.runeSubStr(text, 0, 32) & "..."
##end if
@ -34,6 +34,18 @@
# end case
# end if
#end if
#if result.len == 0 and tweet.articlePreview.isSome:
# let art = tweet.articlePreview.get()
# if art.title.len > 0:
# result = prefix & xmltree.escape(art.title)
# end if
#end if
#if result.len == 0 and tweet.card.isSome:
# let card = tweet.card.get()
# if card.kind notin {hidden, unknown} and card.title.len > 0:
# result = prefix & xmltree.escape(card.title)
# end if
#end if
#end proc
#
#proc getDescription(desc: string; cfg: Config): string =
@ -60,6 +72,72 @@ Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)}
#end case
#end proc
#
#proc renderRssCard(card: Card; prefs: Prefs; urlPrefix: string): string =
#let cardLink = if card.url.startsWith("/"): urlPrefix & card.url else: replaceUrls(card.url, prefs)
#let title = xmltree.escape(card.title)
<hr/>
<b>Link</b><br>
#if cardLink.len > 0:
<a href="${xmltree.escape(cardLink)}">
#end if
#if card.image.len > 0:
<img src="${urlPrefix}${getPicUrl(card.image)}" style="max-width:250px;" />
#end if
#if title.len > 0:
# if card.image.len > 0:
<br>
# end if
<b>${title}</b>
#end if
#if cardLink.len > 0:
</a>
#end if
#if card.text.len > 0:
<p>${xmltree.escape(card.text)}</p>
#end if
#if cardLink.len > 0:
# let destText = if card.dest.len > 0: xmltree.escape(card.dest) else: xmltree.escape(cardLink)
<small><a href="${xmltree.escape(cardLink)}">${destText}</a></small>
#elif card.dest.len > 0:
<small>${xmltree.escape(card.dest)}</small>
#end if
#end proc
#
#proc renderRssArticle(article: ArticlePreview; urlPrefix: string): string =
#let link = urlPrefix & "/i/article/" & $article.tweetId
<hr/>
<b>Article</b><br>
<a href="${link}">
#if article.coverImage.len > 0:
<img src="${urlPrefix}${getPicUrl(article.coverImage)}" style="max-width:250px;" />
#end if
#if article.title.len > 0:
# if article.coverImage.len > 0:
<br>
# end if
<b>${xmltree.escape(article.title)}</b>
#end if
</a>
#if article.previewText.len > 0:
<p>${xmltree.escape(article.previewText)}</p>
#end if
#end proc
#
#proc renderRssPoll(poll: Poll): string =
<hr/>
<b>Poll</b>
<p>
#for i in 0 ..< poll.options.len:
# let perc = if poll.votes > 0: poll.values[i] / poll.votes * 100 else: 0.0
# let pct = (&"{perc:.0f}").strip(chars={'.'})
# let line = pct & "% — " & xmltree.escape(poll.options[i])
${line}<br>
#end for
#let votesStr = insertSep($poll.votes, ',')
<i>${votesStr} votes • ${xmltree.escape(poll.status)}</i>
</p>
#end proc
#
#proc getTweetsWithPinned(profile: Profile): seq[Tweets] =
#result = profile.tweets.content
#if profile.pinned.isSome and result.len > 0:
@ -82,16 +160,21 @@ Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)}
#let tweet = tweet.retweet.get(tweet)
#let urlPrefix = getUrlPrefix(cfg)
#let text = replaceUrls(tweet.text, prefs, absolute=urlPrefix)
#if text.len > 0:
<p>${text.replace("\n", "<br>\n")}</p>
#end if
#if tweet.media.len > 0:
# for media in tweet.media:
${renderRssMedia(media, tweet, urlPrefix)}
# end for
#elif tweet.card.isSome:
# let card = tweet.card.get()
# if card.image.len > 0:
<img src="${urlPrefix}${getPicUrl(card.image)}" style="max-width:250px;" />
# end if
#elif tweet.card.isSome and tweet.card.get().kind notin {hidden, unknown}:
${renderRssCard(tweet.card.get(), prefs, urlPrefix)}
#end if
#if tweet.articlePreview.isSome:
${renderRssArticle(tweet.articlePreview.get(), urlPrefix)}
#end if
#if tweet.poll.isSome:
${renderRssPoll(tweet.poll.get())}
#end if
#if tweet.note.len > 0 and not prefs.hideCommunityNotes:
<p><b>Community note:</b> ${replaceUrls(tweet.note, prefs, absolute=urlPrefix)}</p>

86
src/views/space.nim Normal file
View file

@ -0,0 +1,86 @@
# SPDX-License-Identifier: AGPL-3.0-only
import strutils, times
import karax/[karaxdsl, vdom]
import renderutils
import ".."/[types, utils, formatters]
proc renderParticipant(p: SpaceParticipant; role: string): VNode =
buildHtml(tdiv(class="space-participant")):
a(href=("/" & p.username)):
genImg(p.avatarUrl.replace("_normal", "_bigger"))
tdiv(class="participant-info"):
tdiv(class="participant-name"):
strong: text p.displayName
if p.isVerified:
tdiv(class="verified-icon blue"):
icon "circle", class="verified-icon-circle", title="Verified account"
icon "ok", class="verified-icon-check", title="Verified account"
if role.len > 0:
span(class="host-badge"): text role
span(class="participant-username"): text "@" & p.username
proc renderSpace*(sp: AudioSpace; prefs: Prefs; path: string): VNode =
let
isLive = sp.state == "RUNNING"
source = if prefs.proxyVideos and sp.m3u8Url.startsWith("http"):
getVidUrl(sp.m3u8Url) else: sp.m3u8Url
stateText =
if isLive: "LIVE"
elif sp.endTime.year > 1: "Ended " & sp.endTime.format("MMM d, YYYY")
elif sp.state.len > 0: sp.state
else: "Ended"
durationMs =
if sp.startTime.year > 1 and sp.endTime.year > 1:
int((sp.endTime - sp.startTime).inMilliseconds)
else: 0
duration = if durationMs > 0: getDuration(durationMs) else: ""
totalListeners =
if sp.totalReplayWatched > 0: sp.totalReplayWatched
else: sp.totalLiveListeners
buildHtml(tdiv(class="space-page")):
tdiv(class="space-panel"):
tdiv(class="space-player"):
if sp.m3u8Url.len > 0 and prefs.hlsPlayback:
audio(data-url=source, data-autoload="false")
verbatim "<div class=\"video-overlay\" onclick=\"playAudio(this)\">"
tdiv(class="overlay-circle"): span(class="overlay-triangle")
if isLive:
tdiv(class="space-live"): text "LIVE"
elif duration.len > 0:
tdiv(class="overlay-duration"): text duration
verbatim "</div>"
elif sp.m3u8Url.len > 0:
tdiv(class="video-overlay"):
buttonReferer "/enablehls", "Enable hls playback", path
if isLive:
tdiv(class="space-live"): text "LIVE"
elif duration.len > 0:
tdiv(class="overlay-duration"): text duration
elif sp.availableForReplay:
tdiv(class="video-overlay"):
p: text "Audio stream unavailable"
else:
tdiv(class="video-overlay"):
p: text "Replay is not available"
tdiv(class="space-info"):
tdiv(class="space-header"):
h2(class="space-title"): text sp.title
tdiv(class="space-meta"):
if totalListeners > 0:
span(class="listener-count"): text insertSep($totalListeners, ',') & " listeners"
if isLive:
span(class="space-live"): text stateText
else:
span(class="space-state"): text stateText
if sp.admins.len > 0 or sp.speakers.len > 0:
tdiv(class="space-participants"):
for admin in sp.admins:
let role = if admin.username == sp.creator.username: "Host"
else: "Co-host"
renderParticipant(admin, role)
for speaker in sp.speakers:
renderParticipant(speaker, "")

View file

@ -154,7 +154,8 @@ proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string;
else: renderThread(thread, prefs, path, bigThumb)
else:
for thread in filtered:
if thread.len == 1: renderTweet(thread[0], prefs, path)
if thread.len == 1:
renderTweet(thread[0], prefs, path)
else: renderThread(thread, prefs, path)
var cursor = getSearchMaxId(results, path)

View file

@ -9,17 +9,36 @@ import general
const doctype = "<!DOCTYPE html>\n"
proc renderMiniAvatar(user: User; prefs: Prefs): VNode =
proc renderMiniAvatar*(user: User; prefs: Prefs): VNode =
genImg(user.getUserPic("_mini"), class=(prefs.getAvatarClass & " mini"))
proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): VNode =
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", "Pinned Tweet"
span: icon("pin", pinnedLabel)
elif retweet.len > 0:
tdiv(class="retweet-header"):
span: icon "retweet", retweet & " retweeted"
span: icon("retweet", retweet & " retweeted")
tdiv(class="tweet-header"):
a(class="tweet-avatar", href=("/" & tweet.user.username)):
@ -66,7 +85,7 @@ proc renderVideoDisabled(playbackType: VideoType; path=""): VNode =
buildHtml(tdiv(class="video-overlay")):
case playbackType
of mp4:
p: text "mp4 playback disabled in preferences"
buttonReferer "/enablemp4", "Enable mp4 playback", path
of m3u8, vmap:
buttonReferer "/enablehls", "Enable hls playback", path
@ -78,6 +97,12 @@ proc renderVideoUnavailable(video: Video): VNode =
else:
p: text "This media is unavailable"
proc getVideoDownloadUrl(videoData: Video): string =
let mp4Vars = videoData.variants.filterIt(it.contentType == mp4)
if mp4Vars.len == 0: return ""
let best = mp4Vars.sortedByIt(it.bitrate)[^1].url
if best.startsWith("http"): getVidUrl(best) else: best
proc renderVideoAttachment(videoData: Video; prefs: Prefs; path=""; bigThumb=false): VNode =
let
playbackType = if not prefs.proxyVideos and videoData.hasMp4Url: mp4
@ -108,6 +133,11 @@ proc renderVideoAttachment(videoData: Video; prefs: Prefs; path=""; bigThumb=fal
if videoData.durationMs > 0:
tdiv(class="overlay-duration"): text getDuration(videoData)
verbatim "</div>"
if videoData.available:
let dlUrl = getVideoDownloadUrl(videoData)
if dlUrl.len > 0:
a(class="video-download", href=dlUrl, download="",
title="Download video"): icon "download-alt"
proc renderVideo*(video: Video; prefs: Prefs; path: string; bigThumb=false): VNode =
let hasCardContent = video.description.len > 0 or video.title.len > 0
@ -121,13 +151,13 @@ proc renderVideo*(video: Video; prefs: Prefs; path: string; bigThumb=false): VNo
if video.description.len > 0:
p(class="card-description"): text video.description
proc renderGifAttachment(gif: Gif; prefs: Prefs): VNode =
proc renderGifAttachment(gif: Gif; prefs: Prefs; path=""): VNode =
let thumb = getSmallPic(gif.thumb)
buildHtml(tdiv(class="attachment")):
if not prefs.mp4Playback:
img(src=thumb, loading="lazy")
renderVideoDisabled(mp4)
renderVideoDisabled(mp4, path)
elif prefs.autoplayGifs:
video(class="gif", poster=thumb, autoplay="", muted="", loop=""):
source(src=getPicUrl(gif.url), `type`="video/mp4")
@ -137,9 +167,9 @@ proc renderGifAttachment(gif: Gif; prefs: Prefs): VNode =
if gif.altText.len > 0:
renderAltText(gif.altText)
proc renderGif(gif: Gif; prefs: Prefs): VNode =
proc renderGif(gif: Gif; prefs: Prefs; path=""): VNode =
buildHtml(tdiv(class="attachments media-gif")):
renderGifAttachment(gif, prefs)
renderGifAttachment(gif, prefs, path)
proc renderMedia(media: seq[Media]; prefs: Prefs; path: string; bigThumb=false): VNode =
if media.len == 0:
@ -150,7 +180,7 @@ proc renderMedia(media: seq[Media]; prefs: Prefs; path: string; bigThumb=false):
if item.kind == videoMedia:
return renderVideo(item.video, prefs, path, bigThumb)
if item.kind == gifMedia:
return renderGif(item.gif, prefs)
return renderGif(item.gif, prefs, path)
let
groups = if media.len < 3: @[media]
@ -169,7 +199,7 @@ proc renderMedia(media: seq[Media]; prefs: Prefs; path: string; bigThumb=false):
of videoMedia:
renderVideoAttachment(mediaItem.video, prefs, path, bigThumb)
of gifMedia:
renderGifAttachment(mediaItem.gif, prefs)
renderGifAttachment(mediaItem.gif, prefs, path)
proc renderPoll(poll: Poll): VNode =
buildHtml(tdiv(class="poll")):
@ -225,7 +255,7 @@ func formatStat(stat: int): string =
if stat > 0: insertSep($stat, ',')
else: ""
proc renderStats(stats: TweetStats): VNode =
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)
@ -239,8 +269,9 @@ proc renderReply(tweet: Tweet): VNode =
if i > 0: text " "
a(href=("/" & u)): text "@" & u
proc renderAttribution(user: User; prefs: Prefs): VNode =
buildHtml(a(class="attribution", href=("/" & user.username))):
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)
@ -307,6 +338,9 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode =
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)
@ -322,10 +356,10 @@ proc renderDisclosures*(tweet: Tweet): VNode =
buildHtml(tdiv(class="disclosures")):
if tweet.isAI:
span(data-disclosure="ai"):
icon "attention", "Made with AI"
icon "attention-circled", "Made with AI"
if tweet.isAd:
span(data-disclosure="ad"):
icon "attention", "Paid partnership (ad)"
icon "attention-circled", "Paid partnership (ad)"
proc renderLocation*(tweet: Tweet): string =
let (place, url) = tweet.getLocation()
@ -339,7 +373,8 @@ proc renderLocation*(tweet: Tweet): string =
return $node
proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
last=false; mainTweet=false; afterTweet=false; bigThumb=false): VNode =
last=false; mainTweet=false; afterTweet=false;
bigThumb=false): VNode =
var divClass = class
if index == -1 or last:
divClass = "thread-last " & class
@ -372,7 +407,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
a(class="tweet-link", href=getLink(tweet))
tdiv(class="tweet-body"):
renderHeader(tweet, retweet, pinned, prefs)
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):
@ -386,11 +421,14 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
verbatim replaceUrls(tweet.text, prefs) & renderLocation(tweet)
if tweet.attribution.isSome:
renderAttribution(tweet.attribution.get(), prefs)
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)