Merge branch 'zedeus:master' into master

This commit is contained in:
Salastil 2026-08-14 22:57:40 -04:00 • committed by GitHub
commit 162a15f7a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 1552 additions and 178 deletions

View file

@ -1,25 +1,76 @@
# SPDX-License-Identifier: AGPL-3.0-only
import options
import karax/[karaxdsl, vdom]
from jester import Request
import ".."/[types, formatters]
import ".."/[types, formatters, prefs]
import general, tweet
const doctype = "<!DOCTYPE html>\n"
const
doctype = "<!DOCTYPE html>\n"
embedResizeJs = staticRead("../../public/js/embedResize.js")
proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string =
let
let
video = tweet.getVideos()[0]
thumb = video.thumb
vidUrl = getVideoEmbed(cfg, tweet.id)
prefs = Prefs(hlsPlayback: true, mp4Playback: true)
prefs = Prefs(hlsPlayback: true, mp4Playback: true, proxyVideos: defaultPrefs.proxyVideos)
tweetUrl = getLink(tweet)
let node = buildHtml(html(lang="en")):
renderHead(prefs, cfg, req, video=vidUrl, images=(@[thumb]))
base(target="_blank")
body:
tdiv(class="embed-video"):
renderVideo(video, prefs, "")
a(class="video-overlay-link", href=tweetUrl):
text "Watch on " & cfg.hostname
script:
verbatim embedResizeJs
result = doctype & $node
proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; req: Request): string =
let node = buildHtml(html(lang="en")):
renderHead(prefs, cfg, req)
base(target="_blank")
body:
tdiv(class="embed-wrapper"):
tdiv(class="tweet-embed"):
a(class="tweet-link", href=getLink(tweet))
renderTweet(tweet, prefs, path, mainTweet=true)
a(class="embed-footer", href=getLink(tweet)):
text "Read more on " & cfg.hostname
script:
verbatim embedResizeJs
result = doctype & $node
proc renderErrorEmbed*(error: string; prefs: Prefs; cfg: Config; req: Request;
tweetId = ""; username = ""): string =
let link = if tweetId.len > 0:
if username.len > 0: "/" & username & "/status/" & tweetId
else: "/i/status/" & tweetId
else: "/"
let node = buildHtml(html(lang="en")):
renderHead(prefs, cfg, req)
base(target="_blank")
body:
tdiv(class="embed-wrapper"):
tdiv(class="tweet-embed error-embed"):
a(class="tweet-link", href=link)
tdiv(class="error-panel"):
span: text error
a(class="embed-footer", href=link):
text "Read more on " & cfg.hostname
script:
verbatim embedResizeJs
result = doctype & $node

View file

@ -38,7 +38,7 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode =
proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
video=""; images: seq[string] = @[]; banner=""; ogTitle="";
rss=""; alternate=""): VNode =
rss=""; alternate=""; oembed=""): VNode =
let theme = prefs.theme.toTheme
let ogType =
@ -50,7 +50,7 @@ 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=45")
link(rel="stylesheet", type="text/css", href="/css/style.css?v=106")
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=7")
if theme.len > 0:
@ -70,6 +70,10 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
if rss.len > 0:
link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed")
if oembed.len > 0:
let oembedTitle = if titleText.len > 0: titleText else: "oEmbed"
link(rel="alternate", type="application/json+oembed", href=oembed, title=oembedTitle)
if prefs.hlsPlayback:
script(src="/js/hls.min.js", `defer`="")
script(src="/js/hlsPlayback.js?v=1", `defer`="")
@ -124,7 +128,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs;
titleText=""; desc=""; ogTitle=""; rss=""; video="";
images: seq[string] = @[]; banner="";
twitterLink=""): string =
twitterLink=""; oembed=""): string =
let twitterLink =
if twitterLink.len > 0: twitterLink
@ -132,7 +136,7 @@ proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs;
let node = buildHtml(html(lang="en")):
renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle,
rss, twitterLink)
rss, twitterLink, oembed)
let bodyClass = if prefs.stickyNav: "fixed-nav" else: ""
body(class=bodyClass):

7
src/views/oembed.nimf Normal file
View file

@ -0,0 +1,7 @@
#? stdtmpl(subsChar = '$', metaChar = '#')
## SPDX-License-Identifier: AGPL-3.0-only
#proc renderOembedIframe*(embedUrl: string; maxwidth = 550): string =
# result = ""
<iframe src="$embedUrl" style="width:100%;max-width:${maxwidth}px;height:250px;border:none" scrolling="no" loading="lazy" onload="let c=new MessageChannel;c.port1.onmessage=e=>this.style.height=e.data+'px';this.contentWindow.postMessage('','*',[c.port2])"></iframe>
# result = result.strip()
#end proc

View file

@ -115,7 +115,7 @@ proc renderProtected*(username: string): VNode =
proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode =
profile.tweets.query.fromUser = @[profile.user.username]
let
isGalleryView = profile.tweets.query.kind == media and
isGalleryView = profile.tweets.query.kind == QueryKind.media and
profile.tweets.query.view == "gallery"
viewClass = if isGalleryView: " media-only" else: ""

View file

@ -40,28 +40,45 @@ proc renderProfileTabs*(query: Query; username: string): VNode =
li(class=query.getTabClass(tweets)):
a(href=(link & "/search")): text "Search"
proc renderMediaViewTabs*(query: Query; username: string): VNode =
proc mediaViewUrl(query: Query; view: string): string =
var q = query
q.view = view
"?" & genQueryUrl(q)
proc renderMediaViewTabs*(query: Query): VNode =
let currentView = if query.view.len > 0: query.view else: "timeline"
let base = "/" & username & "/media?view="
func cls(view: string): string =
if currentView == view: "tab-item active" else: "tab-item"
buildHtml(ul(class="tab media-view-tabs")):
li(class=cls("timeline")):
a(href=(base & "timeline")): text "Timeline"
a(href=query.mediaViewUrl("timeline")): text "Timeline"
li(class=cls("grid")):
a(href=(base & "grid")): text "Grid"
a(href=query.mediaViewUrl("grid")): text "Grid"
li(class=cls("gallery")):
a(href=(base & "gallery")): text "Gallery"
a(href=query.mediaViewUrl("gallery")): text "Gallery"
proc renderSearchTabs*(query: Query): VNode =
var q = query
# the media view mode only applies to the Media tab
q.view = ""
buildHtml(ul(class="tab")):
li(class=query.getTabClass(top)):
q.kind = top
a(href=("?" & genQueryUrl(q))): text "Top"
li(class=query.getTabClass(tweets)):
q.kind = tweets
a(href=("?" & genQueryUrl(q))): text "Tweets"
a(href=("?" & genQueryUrl(q))): text "Latest"
li(class=query.getTabClass(media)):
q.kind = media
q.view = query.view
a(href=("?" & genQueryUrl(q))): text "Media"
li(class=query.getTabClass(users)):
q.kind = users
q.view = ""
a(href=("?" & genQueryUrl(q))): text "Users"
li(class=query.getTabClass(lists)):
q.kind = lists
a(href=("?" & genQueryUrl(q))): text "Lists"
proc isPanelOpen(q: Query): bool =
q.fromUser.len == 0 and (q.filters.len > 0 or q.excludes.len > 0 or
@ -72,7 +89,7 @@ proc renderSearchPanel*(query: Query): VNode =
let action = if user.len > 0: &"/{user}/search" else: "/search"
buildHtml(form(`method`="get", action=action,
class="search-field", autocomplete="off")):
hiddenField("f", "tweets")
hiddenField("f", $query.kind)
genInput("q", "", query.text, "Enter search...", class="pref-inline")
button(`type`="submit"): icon "search"
@ -103,33 +120,52 @@ proc renderSearchPanel*(query: Query): VNode =
proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string;
pinned=none(Tweet)): VNode =
let query = results.query
buildHtml(tdiv(class="timeline-container")):
let containerClass =
if query.fromUser.len == 0 and query.kind == QueryKind.media and
query.view == "gallery": "timeline-container media-only"
else: "timeline-container"
buildHtml(tdiv(class=containerClass)):
if query.fromUser.len > 1:
tdiv(class="timeline-header"):
text query.fromUser.join(" | ")
if query.fromUser.len > 0:
if query.kind != media or query.view != "gallery":
if query.kind != QueryKind.media or query.view != "gallery":
renderProfileTabs(query, query.fromUser.join(","))
if query.kind == media and query.fromUser.len == 1:
renderMediaViewTabs(query, query.fromUser[0])
if query.kind == QueryKind.media and query.fromUser.len == 1:
renderMediaViewTabs(query)
if query.fromUser.len == 0 or query.kind == tweets:
if query.fromUser.len == 0 or query.kind == QueryKind.tweets:
tdiv(class="timeline-header"):
renderSearchPanel(query)
if query.fromUser.len == 0:
renderSearchTabs(query)
if query.kind == QueryKind.media:
renderMediaViewTabs(query)
renderTimelineTweets(results, prefs, path, pinned)
proc renderSearchForm(kind, placeholder, value: string): VNode =
buildHtml(form(`method`="get", action="/search",
class="search-field", autocomplete="off")):
hiddenField("f", kind)
genInput("q", "", value, placeholder, class="pref-inline")
button(`type`="submit"): icon "search"
proc renderUserSearch*(results: Result[User]; prefs: Prefs): VNode =
buildHtml(tdiv(class="timeline-container")):
tdiv(class="timeline-header"):
form(`method`="get", action="/search", class="search-field", autocomplete="off"):
hiddenField("f", "users")
genInput("q", "", results.query.text, "Enter username...", class="pref-inline")
button(`type`="submit"): icon "search"
renderSearchForm("users", "Enter username...", results.query.text)
renderSearchTabs(results.query)
renderTimelineUsers(results, prefs)
proc renderListSearch*(results: Result[ListSearchResult]; prefs: Prefs;
path: string): VNode =
buildHtml(tdiv(class="timeline-container")):
tdiv(class="timeline-header"):
renderSearchForm("lists", "Enter search...", results.query.text)
renderSearchTabs(results.query)
renderTimelineLists(results, prefs, path)

View file

@ -1,4 +1,5 @@
# SPDX-License-Identifier: AGPL-3.0-only
import sequtils
import karax/[karaxdsl, vdom]
import ".."/[types, formatters]
@ -28,21 +29,46 @@ proc renderReplyThread(thread: Chain; prefs: Prefs; path: string): VNode =
if thread.hasMore:
renderMoreReplies(thread)
proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string; tweet: Tweet = nil): VNode =
proc renderReplySort(sort: RankingMode): VNode =
buildHtml(tdiv(class="reply-sort")):
span(class="reply-sort-label"): text "Sort replies:"
for mode in RankingMode:
let
cls = if mode == sort: "reply-sort-option active"
else: "reply-sort-option"
label = case mode
of Relevance: "Relevant"
of Recency: "Recent"
of Likes: "Liked"
a(class=cls, href=("?sort=" & $mode & "#r")):
text label
proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string;
tweet: Tweet = nil; sort = Relevance): VNode =
buildHtml(tdiv(class="replies", id="r")):
var hasReplies = false
var replyCount = 0
for thread in replies.content:
if thread.content.len == 0: continue
if thread.content.len == 0 or thread.related: continue
hasReplies = true
replyCount += thread.content.len
renderReplyThread(thread, prefs, path)
if hasReplies and replies.bottom.len > 0:
if tweet == nil or not replies.beginning or replyCount < tweet.stats.replies:
renderMore(Query(), replies.bottom, focus="#r")
let extra = if sort == Relevance: "" else: "sort=" & $sort & "&"
renderMore(Query(), replies.bottom, focus="#r", extra=extra)
proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode =
proc renderRelated(replies: Result[Chain]; prefs: Prefs; path: string): VNode =
buildHtml(tdiv(class="related-tweets")):
tdiv(class="related-header"):
text "Related tweets"
for thread in replies.content:
if thread.content.len == 0 or not thread.related: continue
renderReplyThread(thread, prefs, path)
proc renderConversation*(conv: Conversation; prefs: Prefs; path: string;
sort = Relevance): VNode =
let hasAfter = conv.after.content.len > 0
let threadId = conv.tweet.threadId
buildHtml(tdiv(class="conversation")):
@ -75,7 +101,12 @@ proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode
if not conv.replies.beginning:
renderNewer(Query(), getLink(conv.tweet), focus="#r")
if conv.replies.content.len > 0 or conv.replies.bottom.len > 0:
renderReplies(conv.replies, prefs, path, conv.tweet)
renderReplySort(sort)
renderReplies(conv.replies, prefs, path, conv.tweet, sort)
if not prefs.hideRelated:
if conv.replies.content.anyIt(it.related and it.content.len > 0):
renderRelated(conv.replies, prefs, path)
renderToTop(focus="#m")

View file

@ -6,7 +6,7 @@ import ".."/[types, query, formatters]
import tweet, renderutils
proc timelineViewClass(query: Query): string =
if query.kind != media:
if query.kind != QueryKind.media:
return "timeline"
case query.view
@ -50,9 +50,9 @@ proc renderNewer*(query: Query; path: string; focus=""): VNode =
a(href=(p & url)):
text "Load newest"
proc renderMore*(query: Query; cursor: string; focus=""): VNode =
proc renderMore*(query: Query; cursor: string; focus=""; extra=""): VNode =
buildHtml(tdiv(class="show-more")):
a(href=(&"?{getQuery(query)}cursor={encodeUrl(cursor, usePlus=false)}{focus}")):
a(href=(&"?{extra}{getQuery(query)}cursor={encodeUrl(cursor, usePlus=false)}{focus}")):
text "Load more"
proc renderNoMore(): VNode =
@ -114,6 +114,84 @@ proc renderTimelineUsers*(results: Result[User]; prefs: Prefs; path=""): VNode =
else:
renderNoMore()
proc mentionUsername(word: string): string =
# "@user" -> "user" for well-formed mentions, "" otherwise
if word.len > 1 and word[0] == '@' and
word[1 .. ^1].allCharsInSet({'A'..'Z', 'a'..'z', '0'..'9', '_'}):
word[1 .. ^1]
else: ""
proc mentionedUser(s: string): string =
# last @mention in strings like "65 followers including @user"
let words = s.split(' ')
for i in countdown(words.high, 0):
result = mentionUsername(words[i])
if result.len > 0: return
proc renderMentionedText(s: string): VNode =
# linkify @mentions in plain API strings like "65 followers including @user"
let words = s.split(' ')
buildHtml(span):
for i in 0 ..< words.len:
if i > 0: text " "
let username = mentionUsername(words[i])
if username.len > 0:
a(href=("/" & username)): text words[i]
else:
text words[i]
proc renderListCard(r: ListSearchResult): VNode =
let listUrl = "/i/lists/" & r.list.id
buildHtml(tdiv(class="timeline-item list-result")):
a(class="tweet-link", href=listUrl)
a(class="list-result-banner", href=listUrl):
if r.list.banner.len > 0:
genImg(r.list.banner)
tdiv(class="list-result-body"):
tdiv(class="list-result-title fullname-and-username"):
a(class="list-name fullname", href=listUrl): text r.list.name
span(class="list-members"):
text &"· {insertSep($r.list.members, ',')} members"
tdiv(class="list-result-context"):
if r.followersContext.len > 0:
# the first facepile belongs to the "including @user" account
let mentioned = mentionedUser(r.followersContext)
for i in 0 ..< r.facepiles.len:
if i == 0 and mentioned.len > 0:
a(class="facepile-link", href=("/" & mentioned)):
genImg(r.facepiles[i], class="list-facepile")
else:
genImg(r.facepiles[i], class="list-facepile")
renderMentionedText(r.followersContext)
else:
if r.owner.username.len > 0:
a(class="facepile-link", href=("/" & r.owner.username)):
genImg(r.owner.getUserPic("_mini"), class="list-facepile")
else:
genImg(r.owner.getUserPic("_mini"), class="list-facepile")
linkUser(r.owner, class="fullname")
linkUser(r.owner, class="username")
if r.list.description.len > 0:
tdiv(class="list-result-description"):
text r.list.description
proc renderTimelineLists*(results: Result[ListSearchResult]; prefs: Prefs;
path=""): VNode =
buildHtml(tdiv(class="timeline")):
if not results.beginning:
renderNewer(results.query, path)
if results.content.len > 0:
for list in results.content:
renderListCard(list)
if results.bottom.len > 0:
renderMore(results.query, results.bottom)
renderToTop()
elif results.beginning:
renderNoneFound()
else:
renderNoMore()
proc filterThreads(threads: seq[Tweets]; prefs: Prefs): seq[Tweets] =
var retweets: seq[int64]
for thread in threads:

View file

@ -5,7 +5,6 @@ from jester import Request
import renderutils
import ".."/[types, utils, formatters]
import general
const doctype = "<!DOCTYPE html>\n"
@ -464,13 +463,3 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
if not prefs.hideTweetStats:
renderStats(tweet.stats)
proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; req: Request): string =
let node = buildHtml(html(lang="en")):
renderHead(prefs, cfg, req)
body:
tdiv(class="tweet-embed"):
renderTweet(tweet, prefs, path, mainTweet=true)
result = doctype & $node