From 769cb16a6b7ecb303895ab50555929af8ef04d80 Mon Sep 17 00:00:00 2001 From: taskylizard <75871323+taskylizard@users.noreply.github.com> Date: Mon, 20 May 2024 15:03:29 +0000 Subject: [PATCH] feat: Timeline feed for followed accounts --- src/nitter.nim | 9 +- src/prefs_impl.nim | 5 + src/routes/follow.nim | 42 +++++++++ src/routes/home.nim | 49 ++++++++++ src/sass/profile/card.scss | 181 +++++++++++++++++++------------------ src/views/general.nim | 2 +- src/views/home.nim | 32 +++++++ src/views/profile.nim | 16 +++- src/views/renderutils.nim | 4 + src/views/timeline.nim | 11 ++- 10 files changed, 253 insertions(+), 98 deletions(-) create mode 100644 src/routes/follow.nim create mode 100644 src/routes/home.nim create mode 100644 src/views/home.nim diff --git a/src/nitter.nim b/src/nitter.nim index f976db2..62a6026 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -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.
Use {link} or try again later.", cfg) - + + extend home, "" + extend follow, "" extend rss, "" extend status, "" extend search, "" diff --git a/src/prefs_impl.nim b/src/prefs_impl.nim index 053647d..88c4e1e 100644 --- a/src/prefs_impl.nim +++ b/src/prefs_impl.nim @@ -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" diff --git a/src/routes/follow.nim b/src/routes/follow.nim new file mode 100644 index 0000000..1e5450d --- /dev/null +++ b/src/routes/follow.nim @@ -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()) diff --git a/src/routes/home.nim b/src/routes/home.nim new file mode 100644 index 0000000..dcdbb5e --- /dev/null +++ b/src/routes/home.nim @@ -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) diff --git a/src/sass/profile/card.scss b/src/sass/profile/card.scss index 46a9679..98790a3 100644 --- a/src/sass/profile/card.scss +++ b/src/sass/profile/card.scss @@ -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; } + } } diff --git a/src/views/general.nim b/src/views/general.nim index 87d30f2..3e32f01 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -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: diff --git a/src/views/home.nim b/src/views/home.nim new file mode 100644 index 0000000..57acd2c --- /dev/null +++ b/src/views/home.nim @@ -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) diff --git a/src/views/profile.nim b/src/views/profile.nim index 2ec79f7..f35e7cc 100644 --- a/src/views/profile.nim +++ b/src/views/profile.nim @@ -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) diff --git a/src/views/renderutils.nim b/src/views/renderutils.nim index f298fad..86a6806 100644 --- a/src/views/renderutils.nim +++ b/src/views/renderutils.nim @@ -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 diff --git a/src/views/timeline.nim b/src/views/timeline.nim index abeb6d3..305129b 100644 --- a/src/views/timeline.nim +++ b/src/views/timeline.nim @@ -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"):