?hlsPlayback=on). These overrides aren't saved to cookies, and links won't retain the parameters. Intended for configuring RSS feeds and other cookieless environments. Hover over a preference to see its name."
+
h4(class="note"):
text "Preferences are stored client-side using cookies without any personal information."
diff --git a/src/views/profile.nim b/src/views/profile.nim
index 2b2e410..c9012ed 100644
--- a/src/views/profile.nim
+++ b/src/views/profile.nim
@@ -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,7 +12,14 @@ proc renderStat(num: int; class: string; text=""): VNode =
span(class="profile-stat-num"):
text insertSep($num, ',')
-proc renderUserCard*(user: User; prefs: Prefs): VNode =
+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"):
let
@@ -26,6 +33,7 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
tdiv(class="profile-card-tabs-name"):
linkUser(user, class="profile-card-fullname")
+ verifiedIcon(user)
linkUser(user, class="profile-card-username")
tdiv(class="profile-card-extra"):
@@ -45,6 +53,11 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
else:
span: text place
+ if info.basedIn.len > 0:
+ tdiv(class="profile-location"):
+ span: icon "location"
+ span: text "Based in " & info.basedIn
+
if user.website.len > 0:
tdiv(class="profile-website"):
span:
@@ -53,14 +66,14 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
a(href=url): text url.shortLink
tdiv(class="profile-joindate"):
- span(title=getJoinDateFull(user)):
+ a(href=(&"/{user.username}/about"), title=getJoinDateFull(user)):
icon "calendar", getJoinDate(user)
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 =
@@ -93,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."
@@ -101,19 +114,52 @@ 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 == QueryKind.media and
+ profile.tweets.query.view == "gallery"
+ viewClass = if isGalleryView: " media-only" else: ""
- buildHtml(tdiv(class="profile-tabs")):
- if not prefs.hideBanner:
+ buildHtml(tdiv(class=("profile-tabs" & viewClass))):
+ if not isGalleryView and not prefs.hideBanner:
tdiv(class="profile-banner"):
renderBanner(profile.user.banner)
- let sticky = if prefs.stickyProfile: " sticky" else: ""
- tdiv(class=("profile-tab" & sticky)):
- renderUserCard(profile.user, prefs)
- if profile.photoRail.len > 0:
- renderPhotoRail(profile)
+ if not isGalleryView:
+ let sticky = if prefs.stickyProfile: " sticky" else: ""
+ tdiv(class=("profile-tab" & sticky)):
+ renderUserCard(profile.user, prefs, profile.accountInfo)
+ if profile.photoRail.len > 0:
+ renderPhotoRail(profile)
if profile.user.protected:
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)
diff --git a/src/views/renderutils.nim b/src/views/renderutils.nim
index 9dffdcb..af2f05d 100644
--- a/src/views/renderutils.nim
+++ b/src/views/renderutils.nim
@@ -4,14 +4,23 @@ import karax/[karaxdsl, vdom, vstyles]
import ".."/[types, utils]
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 icon*(icon: string; text=""; title=""; class=""; href=""): VNode =
+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; label=""; title=""; class=""; href=""): VNode =
var c = "icon-" & icon
if class.len > 0: c = &"{c} {class}"
buildHtml(tdiv(class="icon-container")):
@@ -20,8 +29,17 @@ 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:
+ let lower = ($user.verifiedType).toLowerAscii()
+ buildHtml(tdiv(class=(&"verified-icon {lower}"))):
+ icon "circle", class="verified-icon-circle", title=(&"Verified {lower} account")
+ icon "ok", class="verified-icon-check", title=(&"Verified {lower} account")
+ else:
+ text ""
proc linkUser*(user: User, class=""): VNode =
let
@@ -32,11 +50,10 @@ proc linkUser*(user: User, class=""): VNode =
buildHtml(a(href=href, class=class, title=nameText)):
text nameText
- if isName and user.verified:
- icon "ok", class="verified-icon", title="Verified account"
- if isName and user.protected:
- text " "
- icon "lock", title="Protected account"
+ if isName:
+ if user.protected:
+ text " "
+ icon "lock", title="Protected account"
proc linkText*(text: string; class=""): VNode =
let url = if "http" notin text: https & text else: text
@@ -57,20 +74,20 @@ proc buttonReferer*(action, text, path: string; class=""; `method`="post"): VNod
text text
proc genCheckbox*(pref, label: string; state: bool): VNode =
- buildHtml(label(class="pref-group checkbox-container")):
+ buildHtml(label(class="pref-group checkbox-container", title=pref)):
text label
input(name=pref, `type`="checkbox", checked=state)
span(class="checkbox")
proc genInput*(pref, label, state, placeholder: string; class=""; autofocus=true): VNode =
let p = placeholder
- buildHtml(tdiv(class=("pref-group pref-input " & class))):
+ buildHtml(tdiv(class=("pref-group pref-input " & class), title=pref)):
if label.len > 0:
label(`for`=pref): text label
input(name=pref, `type`="text", placeholder=p, value=state, autofocus=(autofocus and state.len == 0))
proc genSelect*(pref, label, state: string; options: seq[string]): VNode =
- buildHtml(tdiv(class="pref-group pref-input")):
+ buildHtml(tdiv(class="pref-group pref-input", title=pref)):
label(`for`=pref): text label
select(name=pref):
for opt in options:
@@ -82,9 +99,16 @@ proc genDate*(pref, state: string): VNode =
input(name=pref, `type`="date", value=state)
icon "calendar"
-proc genImg*(url: string; class=""): VNode =
+proc genNumberInput*(pref, label, state, placeholder: string; class=""; autofocus=true; min="0"): VNode =
+ let p = placeholder
+ buildHtml(tdiv(class=("pref-group pref-input " & class))):
+ if label.len > 0:
+ label(`for`=pref): text label
+ input(name=pref, `type`="number", placeholder=p, value=state, autofocus=(autofocus and state.len == 0), min=min, step="1")
+
+proc genImg*(url: string; class=""; alt=""): VNode =
buildHtml():
- img(src=getPicUrl(url), class=class, alt="")
+ img(src=getPicUrl(url), class=class, alt=alt, loading="lazy")
proc getTabClass*(query: Query; tab: QueryKind): string =
if query.kind == tab: "tab-item active"
diff --git a/src/views/rss.nimf b/src/views/rss.nimf
index 96f6466..4738705 100644
--- a/src/views/rss.nimf
+++ b/src/views/rss.nimf
@@ -1,83 +1,231 @@
#? stdtmpl(subsChar = '$', metaChar = '#')
## SPDX-License-Identifier: AGPL-3.0-only
-#import strutils, xmltree, strformat, options, unicode
+#import strutils, sequtils, xmltree, strformat, options, unicode
#import ../types, ../utils, ../formatters, ../prefs
+## Snowflake ID cutoff for RSS GUID format transition
+## Corresponds to approximately December 14, 2025 UTC
+#const guidCutoff = 2000000000000000000'i64
#
#proc getTitle(tweet: Tweet; retweet: string): string =
-#if tweet.pinned: result = "Pinned: "
-#elif retweet.len > 0: result = &"RT by @{retweet}: "
-#elif tweet.reply.len > 0: result = &"R to @{tweet.reply[0]}: "
+#var prefix = ""
+#if tweet.pinned: prefix = "Pinned: "
+#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
-#result &= xmltree.escape(text)
-#if result.len > 0: return
+#text = xmltree.escape(text)
+## article tweets' text is just the article link; the title says more
+#if tweet.articlePreview.isSome and tweet.articlePreview.get().title.len > 0:
+# result = prefix & xmltree.escape(tweet.articlePreview.get().title)
+# return
#end if
-#if tweet.photos.len > 0:
-# result &= "Image"
-#elif tweet.video.isSome:
-# result &= "Video"
-#elif tweet.gif.isSome:
-# result &= "Gif"
+#if text.len > 0:
+# result = prefix & text
+# return
+#end if
+#if tweet.media.len > 0:
+# result = prefix
+# let firstKind = tweet.media[0].kind
+# if tweet.media.anyIt(it.kind != firstKind):
+# result &= "Media"
+# else:
+# case firstKind
+# of photoMedia: result &= "Image"
+# of videoMedia: result &= "Video"
+# of gifMedia: result &= "Gif"
+# end case
+# 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 =
-Twitter feed for: ${desc}. Generated by ${cfg.hostname}
+Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)}
#end proc
#
-#proc renderRssTweet(tweet: Tweet; cfg: Config): string =
-#let tweet = tweet.retweet.get(tweet)
-#let urlPrefix = getUrlPrefix(cfg)
-#let text = replaceUrls(tweet.text, defaultPrefs, absolute=urlPrefix)
-${text.replace("\n", "
\n")}
${xmltree.escape(card.text)}
+#end if +#if cardLink.len > 0: +# let destText = if card.dest.len > 0: xmltree.escape(card.dest) else: xmltree.escape(cardLink) +${destText} +#elif card.dest.len > 0: +${xmltree.escape(card.dest)} #end if #end proc # -#proc renderRssTweets(tweets: seq[Tweet]; cfg: Config): string = +#proc renderRssArticle(article: ArticlePreview; urlPrefix: string): string = +#let link = urlPrefix & "/i/article/" & $article.tweetId +${xmltree.escape(article.previewText)}
+#end if +#end proc +# +#proc renderRssPoll(poll: Poll): string = +
+#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}
+#end for
+#let votesStr = insertSep($poll.votes, ',')
+${votesStr} votes • ${xmltree.escape(poll.status)}
+
${text.replace("\n", "
\n")}
Community note: ${replaceUrls(tweet.note, prefs, absolute=urlPrefix)}
+#end if +#if tweet.quote.isSome and get(tweet.quote).available: +# let quoteTweet = get(tweet.quote) +# let quoteLink = urlPrefix & getLink(quoteTweet) ++${quoteTweet.user.fullname} (@${quoteTweet.user.username}) ++#end if +#end proc +# +#proc renderRssTweets(tweets: seq[Tweets]; cfg: Config; prefs: Prefs; userId=""): string = #let urlPrefix = getUrlPrefix(cfg) #var links: seq[string] -#for t in tweets: -# let retweet = if t.retweet.isSome: t.user.username else: "" -# let tweet = if retweet.len > 0: t.retweet.get else: t -# let link = getLink(tweet) -# if link in links: continue -# end if -# links.add link -+${renderRssTweet(quoteTweet, cfg, prefs)} +
+ +