feat: Timeline feed for followed accounts

This commit is contained in:
taskylizard 2024-05-20 15:03:29 +00:00
parent c53b8d4d8a
commit 769cb16a6b
No known key found for this signature in database
GPG Key ID: 1820131ED1A24120
10 changed files with 253 additions and 98 deletions

View File

@ -11,7 +11,7 @@ import types, config, prefs, formatters, redis_cache, http_pool, auth
import views/[general, about]
import routes/[
preferences, timeline, status, media, search, rss, list, debug,
unsupported, embed, resolver, router_utils]
unsupported, embed, resolver, router_utils, home,follow]
const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
const issuesUrl = "https://github.com/zedeus/nitter/issues"
@ -60,9 +60,6 @@ settings:
reusePort = true
routes:
get "/":
resp renderMain(renderSearch(), request, cfg, themePrefs())
get "/about":
resp renderMain(renderAbout(), request, cfg, themePrefs())
@ -94,7 +91,9 @@ routes:
const link = a("another instance", href = instancesUrl)
resp Http429, showError(
&"Instance has been rate limited.<br>Use {link} or try again later.", cfg)
extend home, ""
extend follow, ""
extend rss, ""
extend status, ""
extend search, ""

View File

@ -50,6 +50,11 @@ macro genPrefs*(prefDsl: untyped) =
const `name`*: PrefList = toOrderedTable(`table`)
genPrefs:
Timeline:
following(input, ""):
"A comma-separated list of users to follow."
placeholder: "taskylizard,vercel,nodejs"
Display:
theme(select, "Nitter"):
"Theme"

42
src/routes/follow.nim Normal file
View File

@ -0,0 +1,42 @@
import jester, asyncdispatch, strutils, sequtils
import router_utils
import ../types
export follow
proc addUserToFollowing*(following, toAdd: string): string =
var updated = following.split(",")
if updated == @[""]:
return toAdd
elif toAdd in updated:
return following
else:
updated = concat(updated, @[toAdd])
result = updated.join(",")
proc removeUserFromFollowing*(following, remove: string): string =
var updated = following.split(",")
if updated == @[""]:
return ""
else:
updated = filter(updated, proc(x: string): bool = x != remove)
result = updated.join(",")
proc createFollowRouter*(cfg: Config) =
router follow:
post "/follow/@name":
let
following = cookiePrefs().following
toAdd = @"name"
updated = addUserToFollowing(following, toAdd)
setCookie("following", updated, daysForward(if isEmptyOrWhitespace(updated): -10 else: 360),
httpOnly=true, secure=cfg.useHttps, path="/")
redirect(refPath())
post "/unfollow/@name":
let
following = cookiePrefs().following
remove = @"name"
updated = removeUserFromFollowing(following, remove)
setCookie("following", updated, daysForward(360),
httpOnly=true, secure=cfg.useHttps, path="/")
redirect(refPath())

49
src/routes/home.nim Normal file
View File

@ -0,0 +1,49 @@
import jester
import asyncdispatch, strutils, options, router_utils, timeline
import ".."/[prefs, types, utils, redis_cache]
import ../views/[general, home, search]
export home
proc showHome*(request: Request; query: Query; cfg: Config; prefs: Prefs;
after: string): Future[string] {.async.} =
let
timeline = await getGraphTweetSearch(query, after)
html = renderHome(timeline, prefs, getPath())
return renderMain(html, request, cfg, prefs)
proc createHomeRouter*(cfg: Config) =
router home:
get "/":
let
prefs = cookiePrefs()
after = getCursor()
names = getNames(prefs.following)
var query = request.getQuery("", prefs.following)
query.fromUser = names
if @"scroll".len > 0:
var timeline = await getGraphTweetSearch(query, after)
if timeline.content.len == 0: resp Http404
timeline.beginning = true
resp $renderHome(timeline, prefs, getPath())
if names.len == 0:
resp renderMain(renderSearch(), request, cfg, themePrefs())
resp (await showHome(request, query, cfg, prefs, after))
get "/following":
let
prefs = cookiePrefs()
names = getNames(prefs.following)
var
profs: seq[User]
query = request.getQuery("", prefs.following)
query.fromUser = names
query.kind = userList
for name in names:
let prof = await getCachedUser(name)
profs &= @[prof]
resp renderMain(renderFollowing(query, profs, prefs), request, cfg, prefs)

View File

@ -1,130 +1,139 @@
@import '_variables';
@import '_mixins';
@import "_variables";
@import "_mixins";
.profile-card {
flex-wrap: wrap;
background: var(--bg_panel);
padding: 12px;
display: flex;
flex-wrap: wrap;
background: var(--bg_panel);
padding: 12px;
display: flex;
}
.profile-card-info {
@include breakable;
width: 100%;
@include breakable;
width: 100%;
}
.profile-card-tabs-name {
@include breakable;
max-width: 100%;
.profile-card-tabs-name-and-follow {
@include breakable;
width: 100%;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
}
.profile-card-follow-button {
float: none;
}
.profile-card-username {
@include breakable;
color: var(--fg_color);
font-size: 14px;
display: block;
@include breakable;
color: var(--fg_color);
font-size: 14px;
display: block;
}
.profile-card-fullname {
@include breakable;
color: var(--fg_color);
font-size: 16px;
font-weight: bold;
text-shadow: none;
max-width: 100%;
@include breakable;
color: var(--fg_color);
font-size: 16px;
font-weight: bold;
text-shadow: none;
max-width: 100%;
}
.profile-card-avatar {
display: inline-block;
position: relative;
display: inline-block;
position: relative;
width: 100%;
margin-right: 4px;
margin-bottom: 6px;
&:after {
content: "";
display: block;
margin-top: 100%;
}
img {
box-sizing: border-box;
position: absolute;
width: 100%;
margin-right: 4px;
margin-bottom: 6px;
&:after {
content: '';
display: block;
margin-top: 100%;
}
img {
box-sizing: border-box;
position: absolute;
width: 100%;
height: 100%;
border: 4px solid var(--darker_grey);
background: var(--bg_panel);
}
height: 100%;
border: 4px solid var(--darker_grey);
background: var(--bg_panel);
}
}
.profile-card-extra {
display: contents;
flex: 100%;
margin-top: 7px;
display: contents;
flex: 100%;
margin-top: 7px;
.profile-bio {
@include breakable;
width: 100%;
margin: 4px -6px 6px 0;
white-space: pre-wrap;
.profile-bio {
@include breakable;
width: 100%;
margin: 4px -6px 6px 0;
white-space: pre-wrap;
p {
margin: 0;
}
p {
margin: 0;
}
}
.profile-joindate, .profile-location, .profile-website {
color: var(--fg_faded);
margin: 1px 0;
width: 100%;
}
.profile-joindate,
.profile-location,
.profile-website {
color: var(--fg_faded);
margin: 1px 0;
width: 100%;
}
}
.profile-card-extra-links {
margin-top: 8px;
font-size: 14px;
width: 100%;
margin-top: 8px;
font-size: 14px;
width: 100%;
}
.profile-statlist {
display: flex;
flex-wrap: wrap;
padding: 0;
width: 100%;
justify-content: space-between;
display: flex;
flex-wrap: wrap;
padding: 0;
width: 100%;
justify-content: space-between;
li {
display: table-cell;
text-align: center;
}
li {
display: table-cell;
text-align: center;
}
}
.profile-stat-header {
font-weight: bold;
color: var(--profile_stat);
font-weight: bold;
color: var(--profile_stat);
}
.profile-stat-num {
display: block;
color: var(--profile_stat);
display: block;
color: var(--profile_stat);
}
@media(max-width: 700px) {
.profile-card-info {
display: flex;
}
@media (max-width: 700px) {
.profile-card-info {
display: flex;
}
.profile-card-tabs-name {
flex-shrink: 100;
}
.profile-card-tabs-name {
flex-shrink: 100;
}
.profile-card-avatar {
width: 80px;
height: 80px;
.profile-card-avatar {
width: 98px;
height: auto;
img {
border-width: 2px;
width: unset;
}
img {
border-width: 2px;
width: unset;
}
}
}

View File

@ -52,7 +52,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=19")
link(rel="stylesheet", type="text/css", href="/css/style.css?v=20")
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=2")
if theme.len > 0:

32
src/views/home.nim Normal file
View File

@ -0,0 +1,32 @@
import karax/[karaxdsl, vdom]
import search, timeline, renderutils
import ../types
proc renderFollowingUsers*(results: seq[User]; prefs: Prefs): VNode =
buildHtml(tdiv(class="timeline")):
for user in results:
renderUser(user, prefs)
proc renderHomeTabs*(query: Query): VNode =
buildHtml(ul(class="tab")):
li(class=query.getTabClass(posts)):
a(href="/"): text "Tweets"
li(class=query.getTabClass(userList)):
a(href=("/following")): text "Following"
proc renderHome*(results: Timeline; prefs: Prefs; path: string): VNode =
let query = results.query
buildHtml(tdiv(class="timeline-container")):
if query.fromUser.len > 0:
renderHomeTabs(query)
if query.fromUser.len == 0 or query.kind == tweets:
tdiv(class="timeline-header"):
renderSearchPanel(query)
renderTimelineTweets(results, prefs, path)
proc renderFollowing*(query: Query; following: seq[User]; prefs: Prefs): VNode =
buildHtml(tdiv(class="timeline-container")):
renderHomeTabs(query)
renderFollowingUsers(following, prefs)

View File

@ -12,7 +12,7 @@ proc renderStat(num: int; class: string; text=""): VNode =
span(class="profile-stat-num"):
text insertSep($num, ',')
proc renderUserCard*(user: User; prefs: Prefs): VNode =
proc renderUserCard*(user: User; prefs: Prefs; path: string): VNode =
buildHtml(tdiv(class="profile-card")):
tdiv(class="profile-card-info"):
let
@ -24,9 +24,15 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
a(class="profile-card-avatar", href=url, target="_blank"):
genImg(user.getUserPic(size))
tdiv(class="profile-card-tabs-name"):
linkUser(user, class="profile-card-fullname")
linkUser(user, class="profile-card-username")
tdiv(class="profile-card-tabs-name-and-follow"):
tdiv():
linkUser(user, class="profile-card-fullname")
linkUser(user, class="profile-card-username")
let following = isFollowing(user.username, prefs.following)
if not following:
buttonReferer "/follow/" & user.username, "Follow", path, "profile-card-follow-button"
else:
buttonReferer "/unfollow/" & user.username, "Unfollow", path, "profile-card-follow-button"
tdiv(class="profile-card-extra"):
if user.bio.len > 0:
@ -113,7 +119,7 @@ proc renderProfile*(profile: var Profile; cfg: Config; prefs: Prefs; path: strin
let sticky = if prefs.stickyProfile: " sticky" else: ""
tdiv(class=("profile-tab" & sticky)):
renderUserCard(profile.user, prefs)
renderUserCard(profile.user, prefs, path)
if profile.photoRail.len > 0:
renderPhotoRail(profile)

View File

@ -100,3 +100,7 @@ proc getTabClass*(query: Query; tab: QueryKind): string =
proc getAvatarClass*(prefs: Prefs): string =
if prefs.squareAvatars: "avatar"
else: "avatar round"
proc isFollowing*(name, following: string): bool =
let following = following.split(",")
return name in following

View File

@ -55,7 +55,16 @@ proc renderThread(thread: Tweets; prefs: Prefs; path: string): VNode =
renderTweet(tweet, prefs, path, class=(header & "thread"),
index=i, last=(i == thread.high), showThread=show)
proc renderUser(user: User; prefs: Prefs): VNode =
proc threadFilter(tweets: openArray[Tweet]; threads: openArray[int64]; it: Tweet): seq[Tweet] =
result = @[it]
if it.retweet.isSome or it.replyId in threads: return
for t in tweets:
if t.id == result[0].replyId:
result.insert t
elif t.replyId == result[0].id:
result.add t
proc renderUser*(user: User; prefs: Prefs): VNode =
buildHtml(tdiv(class="timeline-item")):
a(class="tweet-link", href=("/" & user.username))
tdiv(class="tweet-body profile-result"):