feat: Timeline feed for followed accounts
This commit is contained in:
parent
c53b8d4d8a
commit
769cb16a6b
10 changed files with 253 additions and 98 deletions
|
@ -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())
|
||||
|
||||
|
@ -95,6 +92,8 @@ routes:
|
|||
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, ""
|
||||
|
|
|
@ -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
42
src/routes/follow.nim
Normal 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
49
src/routes/home.nim
Normal 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)
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
32
src/views/home.nim
Normal 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)
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"):
|
||||
|
|
Loading…
Reference in a new issue