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 views/[general, about]
|
||||||
import routes/[
|
import routes/[
|
||||||
preferences, timeline, status, media, search, rss, list, debug,
|
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 instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
|
||||||
const issuesUrl = "https://github.com/zedeus/nitter/issues"
|
const issuesUrl = "https://github.com/zedeus/nitter/issues"
|
||||||
|
@ -60,9 +60,6 @@ settings:
|
||||||
reusePort = true
|
reusePort = true
|
||||||
|
|
||||||
routes:
|
routes:
|
||||||
get "/":
|
|
||||||
resp renderMain(renderSearch(), request, cfg, themePrefs())
|
|
||||||
|
|
||||||
get "/about":
|
get "/about":
|
||||||
resp renderMain(renderAbout(), request, cfg, themePrefs())
|
resp renderMain(renderAbout(), request, cfg, themePrefs())
|
||||||
|
|
||||||
|
@ -94,7 +91,9 @@ routes:
|
||||||
const link = a("another instance", href = instancesUrl)
|
const link = a("another instance", href = instancesUrl)
|
||||||
resp Http429, showError(
|
resp Http429, showError(
|
||||||
&"Instance has been rate limited.<br>Use {link} or try again later.", cfg)
|
&"Instance has been rate limited.<br>Use {link} or try again later.", cfg)
|
||||||
|
|
||||||
|
extend home, ""
|
||||||
|
extend follow, ""
|
||||||
extend rss, ""
|
extend rss, ""
|
||||||
extend status, ""
|
extend status, ""
|
||||||
extend search, ""
|
extend search, ""
|
||||||
|
|
|
@ -50,6 +50,11 @@ macro genPrefs*(prefDsl: untyped) =
|
||||||
const `name`*: PrefList = toOrderedTable(`table`)
|
const `name`*: PrefList = toOrderedTable(`table`)
|
||||||
|
|
||||||
genPrefs:
|
genPrefs:
|
||||||
|
Timeline:
|
||||||
|
following(input, ""):
|
||||||
|
"A comma-separated list of users to follow."
|
||||||
|
placeholder: "taskylizard,vercel,nodejs"
|
||||||
|
|
||||||
Display:
|
Display:
|
||||||
theme(select, "Nitter"):
|
theme(select, "Nitter"):
|
||||||
"Theme"
|
"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 "_variables";
|
||||||
@import '_mixins';
|
@import "_mixins";
|
||||||
|
|
||||||
.profile-card {
|
.profile-card {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
background: var(--bg_panel);
|
background: var(--bg_panel);
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-card-info {
|
.profile-card-info {
|
||||||
@include breakable;
|
@include breakable;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-card-tabs-name {
|
.profile-card-tabs-name-and-follow {
|
||||||
@include breakable;
|
@include breakable;
|
||||||
max-width: 100%;
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-card-follow-button {
|
||||||
|
float: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-card-username {
|
.profile-card-username {
|
||||||
@include breakable;
|
@include breakable;
|
||||||
color: var(--fg_color);
|
color: var(--fg_color);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-card-fullname {
|
.profile-card-fullname {
|
||||||
@include breakable;
|
@include breakable;
|
||||||
color: var(--fg_color);
|
color: var(--fg_color);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-shadow: none;
|
text-shadow: none;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-card-avatar {
|
.profile-card-avatar {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
position: relative;
|
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%;
|
width: 100%;
|
||||||
margin-right: 4px;
|
height: 100%;
|
||||||
margin-bottom: 6px;
|
border: 4px solid var(--darker_grey);
|
||||||
|
background: var(--bg_panel);
|
||||||
&: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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-card-extra {
|
.profile-card-extra {
|
||||||
display: contents;
|
display: contents;
|
||||||
flex: 100%;
|
flex: 100%;
|
||||||
margin-top: 7px;
|
margin-top: 7px;
|
||||||
|
|
||||||
.profile-bio {
|
.profile-bio {
|
||||||
@include breakable;
|
@include breakable;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 4px -6px 6px 0;
|
margin: 4px -6px 6px 0;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.profile-joindate, .profile-location, .profile-website {
|
.profile-joindate,
|
||||||
color: var(--fg_faded);
|
.profile-location,
|
||||||
margin: 1px 0;
|
.profile-website {
|
||||||
width: 100%;
|
color: var(--fg_faded);
|
||||||
}
|
margin: 1px 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-card-extra-links {
|
.profile-card-extra-links {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-statlist {
|
.profile-statlist {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
||||||
li {
|
li {
|
||||||
display: table-cell;
|
display: table-cell;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-stat-header {
|
.profile-stat-header {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: var(--profile_stat);
|
color: var(--profile_stat);
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-stat-num {
|
.profile-stat-num {
|
||||||
display: block;
|
display: block;
|
||||||
color: var(--profile_stat);
|
color: var(--profile_stat);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media(max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.profile-card-info {
|
.profile-card-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-card-tabs-name {
|
.profile-card-tabs-name {
|
||||||
flex-shrink: 100;
|
flex-shrink: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-card-avatar {
|
.profile-card-avatar {
|
||||||
width: 80px;
|
width: 98px;
|
||||||
height: 80px;
|
height: auto;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
border-width: 2px;
|
border-width: 2px;
|
||||||
width: unset;
|
width: unset;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -52,7 +52,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||||
let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
|
let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
|
||||||
|
|
||||||
buildHtml(head):
|
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")
|
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=2")
|
||||||
|
|
||||||
if theme.len > 0:
|
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"):
|
span(class="profile-stat-num"):
|
||||||
text insertSep($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")):
|
buildHtml(tdiv(class="profile-card")):
|
||||||
tdiv(class="profile-card-info"):
|
tdiv(class="profile-card-info"):
|
||||||
let
|
let
|
||||||
|
@ -24,9 +24,15 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
|
||||||
a(class="profile-card-avatar", href=url, target="_blank"):
|
a(class="profile-card-avatar", href=url, target="_blank"):
|
||||||
genImg(user.getUserPic(size))
|
genImg(user.getUserPic(size))
|
||||||
|
|
||||||
tdiv(class="profile-card-tabs-name"):
|
tdiv(class="profile-card-tabs-name-and-follow"):
|
||||||
linkUser(user, class="profile-card-fullname")
|
tdiv():
|
||||||
linkUser(user, class="profile-card-username")
|
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"):
|
tdiv(class="profile-card-extra"):
|
||||||
if user.bio.len > 0:
|
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: ""
|
let sticky = if prefs.stickyProfile: " sticky" else: ""
|
||||||
tdiv(class=("profile-tab" & sticky)):
|
tdiv(class=("profile-tab" & sticky)):
|
||||||
renderUserCard(profile.user, prefs)
|
renderUserCard(profile.user, prefs, path)
|
||||||
if profile.photoRail.len > 0:
|
if profile.photoRail.len > 0:
|
||||||
renderPhotoRail(profile)
|
renderPhotoRail(profile)
|
||||||
|
|
||||||
|
|
|
@ -100,3 +100,7 @@ proc getTabClass*(query: Query; tab: QueryKind): string =
|
||||||
proc getAvatarClass*(prefs: Prefs): string =
|
proc getAvatarClass*(prefs: Prefs): string =
|
||||||
if prefs.squareAvatars: "avatar"
|
if prefs.squareAvatars: "avatar"
|
||||||
else: "avatar round"
|
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"),
|
renderTweet(tweet, prefs, path, class=(header & "thread"),
|
||||||
index=i, last=(i == thread.high), showThread=show)
|
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")):
|
buildHtml(tdiv(class="timeline-item")):
|
||||||
a(class="tweet-link", href=("/" & user.username))
|
a(class="tweet-link", href=("/" & user.username))
|
||||||
tdiv(class="tweet-body profile-result"):
|
tdiv(class="tweet-body profile-result"):
|
||||||
|
|
Loading…
Reference in a new issue