mirror of
https://github.com/zedeus/nitter
synced 2026-10-26 20:53:25 +00:00
Merge branch 'zedeus:master' into master
This commit is contained in:
commit
106df59291
71 changed files with 3652 additions and 481 deletions
248
src/views/article.nim
Normal file
248
src/views/article.nim
Normal 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
128
src/views/community.nim
Normal 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
86
src/views/space.nim
Normal 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, "")
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue