Compare commits

...

3 Commits

15 changed files with 258 additions and 117 deletions

View File

@@ -10,7 +10,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, 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"
@@ -78,6 +78,7 @@ waitFor initRedisPool(cfg)
info &"Connected to Redis at {cfg.redisHost}:{cfg.redisPort}" info &"Connected to Redis at {cfg.redisHost}:{cfg.redisPort}"
createUnsupportedRouter(cfg) createUnsupportedRouter(cfg)
createFollowRouter(cfg)
createResolverRouter(cfg) createResolverRouter(cfg)
createPrefRouter(cfg) createPrefRouter(cfg)
createTimelineRouter(cfg) createTimelineRouter(cfg)
@@ -145,5 +146,6 @@ routes:
extend preferences, "" extend preferences, ""
extend resolver, "" extend resolver, ""
extend embed, "" extend embed, ""
extend follow, ""
extend debug, "" extend debug, ""
extend unsupported, "" extend unsupported, ""

View File

@@ -1,5 +1,5 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import tables import tables, strutils, sequtils
import types, prefs_impl import types, prefs_impl
from config import get from config import get
from parsecfg import nil from parsecfg import nil
@@ -13,6 +13,8 @@ proc updateDefaultPrefs*(cfg: parsecfg.Config) =
proc getPrefs*(cookies: Table[string, string]): Prefs = proc getPrefs*(cookies: Table[string, string]): Prefs =
result = defaultPrefs result = defaultPrefs
if "nitter_following" in cookies:
result.following = cookies["nitter_following"].split(',').filterIt(it.len > 0)
genCookiePrefs(cookies) genCookiePrefs(cookies)
template getPref*(cookies: Table[string, string], pref): untyped = template getPref*(cookies: Table[string, string], pref): untyped =

View File

@@ -225,6 +225,7 @@ macro genPrefsType*(): untyped =
let name = nnkPostfix.newTree(ident("*"), ident("Prefs")) let name = nnkPostfix.newTree(ident("*"), ident("Prefs"))
result = quote do: result = quote do:
type `name` = object type `name` = object
following*: seq[string]
discard discard
for pref in allPrefs(): for pref in allPrefs():

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

@@ -0,0 +1,23 @@
# SPDX-License-Identifier: AGPL-3.0-only
import strutils, sequtils, uri
import jester
import router_utils
import ".."/[types]
proc createFollowRouter*(cfg: Config) =
router follow:
post "/follow":
let user = @"user"
var prefs = cookiePrefs()
if user.len > 0 and user notin prefs.following:
prefs.following.add(user)
setCookie("nitter_following", prefs.following.join(","), daysForward(360), path="/", httpOnly=true, secure=cfg.useHttps, sameSite=None)
redirect(refPath())
post "/unfollow":
let user = @"user"
var prefs = cookiePrefs()
if user.len > 0 and user in prefs.following:
prefs.following.keepItIf(it != user)
setCookie("nitter_following", prefs.following.join(","), daysForward(360), path="/", httpOnly=true, secure=cfg.useHttps, sameSite=None)
redirect(refPath())

View File

@@ -32,7 +32,7 @@ proc createSearchRouter*(cfg: Config) =
users = await getGraphUserSearch(query, getCursor()) users = await getGraphUserSearch(query, getCursor())
except InternalError: except InternalError:
users = Result[User](beginning: true, query: query) users = Result[User](beginning: true, query: query)
resp renderMain(renderUserSearch(users, prefs), request, cfg, prefs, title) resp renderMain(renderUserSearch(users, prefs, getPath()), request, cfg, prefs, title)
of tweets: of tweets:
let let
tweets = await getGraphTweetSearch(query, getCursor()) tweets = await getGraphTweetSearch(query, getCursor())

View File

@@ -37,3 +37,52 @@
height: unset; height: unset;
} }
} }
.follow-form {
display: inline-block;
pointer-events: all;
}
.follow-button {
background-color: transparent;
color: var(--accent);
border: 1px solid var(--accent);
border-radius: 50%;
padding: 4px 12px;
font-weight: 700;
font-size: 14px;
cursor: pointer;
line-height: 1.2;
transition: background-color 0.2s;
outline: none;
&:hover {
background-color: var(--bg_hover);
}
&.unfollow {
color: #e0245e;
border-color: #e0245e;
&:hover {
background-color: rgba(224, 36, 94, 0.1);
}
}
}
.profile-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex: 1;
min-width: 0;
}
.tweet-name-row .follow-form {
margin-left: 10px;
}
.tweet-name-row .follow-button {
padding: 2px 10px;
font-size: 13px;
}

View File

@@ -61,23 +61,8 @@
@mixin search-resize($width, $rows) { @mixin search-resize($width, $rows) {
@media(max-width: $width) { @media(max-width: $width) {
.search-toggles {
grid-template-columns: repeat($rows, auto);
}
#search-panel-toggle:checked ~ .search-panel { #search-panel-toggle:checked ~ .search-panel {
@if $rows == 6 { max-height: 80vh !important;
max-height: 200px !important;
}
@if $rows == 5 {
max-height: 300px !important;
}
@if $rows == 4 {
max-height: 300px !important;
}
@if $rows == 3 {
max-height: 365px !important;
}
} }
} }
} }

View File

@@ -25,6 +25,14 @@ nav {
align-items: center; align-items: center;
flex-basis: 920px; flex-basis: 920px;
height: 50px; height: 50px;
gap: 15px;
}
.inner-nav .search-field {
flex: 1;
margin: 0 auto;
max-width: 350px;
min-width: 0;
} }
.site-name { .site-name {
@@ -46,11 +54,11 @@ nav {
.nav-item { .nav-item {
display: flex; display: flex;
flex: 1; flex: 0 0 auto;
line-height: 50px; line-height: 50px;
height: 50px; height: 50px;
overflow: hidden; overflow: hidden;
flex-wrap: wrap; flex-wrap: nowrap;
align-items: center; align-items: center;
&.right { &.right {

View File

@@ -9,19 +9,22 @@
.search-field { .search-field {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: nowrap;
position: relative;
button { button {
margin: 0 2px 0 0; margin: 0 2px 0 0;
height: 23px; height: 23px;
display: flex; display: flex;
align-items: center; align-items: center;
flex: 0 0 auto;
} }
.pref-input { .pref-input {
margin: 0 4px 0 0; margin: 0 4px 0 0;
flex-grow: 1; flex-grow: 1;
height: 23px; height: 23px;
min-width: 0;
} }
input[type="text"] { input[type="text"] {
@@ -30,49 +33,86 @@
} }
> label { > label {
display: inline; display: flex;
align-items: center;
height: 23px;
background-color: var(--bg_elements); background-color: var(--bg_elements);
color: var(--fg_color); color: var(--fg_color);
border: 1px solid var(--accent_border); border: 1px solid var(--accent_border);
padding: 1px 6px 2px 6px; padding: 0 6px;
font-size: 14px; font-size: 14px;
cursor: pointer; cursor: pointer;
margin-bottom: 2px; margin-bottom: 0;
box-sizing: border-box;
@include input-colors; @include input-colors;
} }
@include create-toggle(search-panel, 200px); @include create-toggle(search-panel, 600px);
} }
.search-panel { .search-panel {
width: 100%; width: 100%;
margin-top: 5px;
border: 1px solid var(--accent_border);
max-height: 0; max-height: 0;
overflow: hidden; overflow-y: auto;
overflow-x: hidden;
transition: max-height 0.4s; transition: max-height 0.4s;
position: absolute;
top: 100%;
left: 0;
background-color: var(--bg_overlays);
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
border-radius: 0 0 4px 4px;
z-index: 2000;
padding: 5px 10px;
box-sizing: border-box;
opacity: 0;
visibility: hidden;
transition: max-height 0.4s, opacity 0.4s, visibility 0.4s;
flex-grow: 1;
font-weight: initial; font-weight: initial;
text-align: left; text-align: left;
font-size: 13px;
> div { > div {
line-height: 1.7em; line-height: 1.4em;
margin-bottom: 4px;
} }
.checkbox-container { .checkbox-container {
display: inline; display: inline-flex;
align-items: center;
padding-right: unset; padding-right: unset;
margin-bottom: unset; margin-bottom: 2px;
margin-left: 23px; margin-left: 18px;
margin-right: 0;
white-space: nowrap;
} }
.checkbox { .checkbox {
right: unset; right: unset;
left: -22px; left: -18px;
width: 15px;
height: 15px;
} }
.checkbox-container .checkbox:after { .checkbox-container .checkbox:after {
top: -4px; top: 0;
left: 1px;
bottom: unset;
font-size: 13px;
width: auto;
height: auto;
}
.search-title {
font-size: 13px;
margin-top: 2px;
margin-bottom: 2px;
} }
} }
@@ -92,11 +132,11 @@
.pref-input { .pref-input {
display: block; display: block;
padding-bottom: 5px; padding-bottom: 2px;
input { input {
height: 21px; height: 21px;
margin-top: 1px; margin-top: 0;
} }
} }
} }
@@ -104,8 +144,13 @@
.search-toggles { .search-toggles {
flex-grow: 1; flex-grow: 1;
display: grid; display: grid;
grid-template-columns: repeat(6, auto); grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
grid-column-gap: 10px; gap: 5px 10px;
}
#search-panel-toggle:checked ~ .search-panel {
opacity: 1;
visibility: visible;
} }
.profile-tabs { .profile-tabs {
@@ -120,3 +165,16 @@
@include search-resize(560px, 5); @include search-resize(560px, 5);
@include search-resize(480px, 4); @include search-resize(480px, 4);
@include search-resize(410px, 3); @include search-resize(410px, 3);
@media(max-width: 600px) {
.search-panel {
position: fixed;
top: 50px;
left: 0;
right: 0;
width: 100%;
border-radius: 0;
border-top: 1px solid var(--accent_border);
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
}
}

View File

@@ -2,8 +2,8 @@
import uri, strutils, strformat import uri, strutils, strformat
import karax/[karaxdsl, vdom] import karax/[karaxdsl, vdom]
import renderutils import renderutils, search_panel
import ../utils, ../types, ../prefs, ../formatters import ../utils, ../types, ../prefs, ../formatters, ../query, tables
import jester import jester
@@ -20,14 +20,17 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode =
path = $(parseUri(req.path) ? filterParams(req.params)) path = $(parseUri(req.path) ? filterParams(req.params))
if "/status/" in path: path.add "#m" if "/status/" in path: path.add "#m"
let query = initQuery(req.params)
buildHtml(nav): buildHtml(nav):
tdiv(class="inner-nav"): tdiv(class="inner-nav"):
tdiv(class="nav-item"): tdiv(class="nav-item"):
a(class="site-name", href="/"): text cfg.title a(class="site-name", href="/"): text cfg.title
a(href="/about"): text "(donate)" a(href="/about"): text "(donate)"
renderSearchPanel(query)
tdiv(class="nav-item right"): tdiv(class="nav-item right"):
icon "search", title="Search", href="/search"
if cfg.enableRss and rss.len > 0: if cfg.enableRss and rss.len > 0:
icon "rss", title="RSS Feed", href=rss icon "rss", title="RSS Feed", href=rss
icon "bird", title="Open in Twitter", href=canonical icon "bird", title="Open in Twitter", href=canonical

View File

@@ -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 =
let class = if user.sensitive: "profile-card nsfw" else: "profile-card" let class = if user.sensitive: "profile-card nsfw" else: "profile-card"
buildHtml(tdiv(class=class)): buildHtml(tdiv(class=class)):
tdiv(class="profile-card-info"): tdiv(class="profile-card-info"):
@@ -25,9 +25,12 @@ 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-header"):
linkUser(user, class="profile-card-fullname") tdiv(class="profile-card-tabs-name"):
linkUser(user, class="profile-card-username") linkUser(user, class="profile-card-fullname")
linkUser(user, class="profile-card-username")
renderFollowButton(user, prefs, path)
tdiv(class="profile-card-extra"): tdiv(class="profile-card-extra"):
if user.bio.len > 0: if user.bio.len > 0:
@@ -110,7 +113,7 @@ proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode =
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)

View File

@@ -100,3 +100,15 @@ 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 renderFollowButton*(user: User; prefs: Prefs; path: string): VNode =
let
isFollowing = user.username in prefs.following
action = if isFollowing: "/unfollow" else: "/follow"
text = if isFollowing: "Unfollow" else: "Follow"
class = if isFollowing: "follow-button unfollow" else: "follow-button"
buildHtml(form(action=action, `method`="post", class="follow-form")):
refererField path
hiddenField("user", user.username)
button(class=class, `type`="submit"): text text

View File

@@ -1,33 +1,13 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import strutils, strformat, sequtils, unicode, tables, options import strutils, options
import karax/[karaxdsl, vdom] import karax/[karaxdsl, vdom]
import renderutils, timeline import renderutils, timeline
import ".."/[types, query] import ".."/[types, query]
const toggles = {
"nativeretweets": "Retweets",
"media": "Media",
"videos": "Videos",
"news": "News",
"verified": "Verified",
"native_video": "Native videos",
"replies": "Replies",
"links": "Links",
"images": "Images",
"safe": "Safe",
"quote": "Quotes",
"pro_video": "Pro videos"
}.toOrderedTable
proc renderSearch*(): VNode = proc renderSearch*(): VNode =
buildHtml(tdiv(class="panel-container")): buildHtml(tdiv(class="panel-container")):
tdiv(class="search-bar"): discard
form(`method`="get", action="/search", autocomplete="off"):
hiddenField("f", "tweets")
input(`type`="text", name="q", autofocus="",
placeholder="Search...", dir="auto")
button(`type`="submit"): icon "search"
proc renderProfileTabs*(query: Query; username: string): VNode = proc renderProfileTabs*(query: Query; username: string): VNode =
let link = "/" & username let link = "/" & username
@@ -51,43 +31,6 @@ proc renderSearchTabs*(query: Query): VNode =
q.kind = users q.kind = users
a(href=("?" & genQueryUrl(q))): text "Users" a(href=("?" & genQueryUrl(q))): text "Users"
proc isPanelOpen(q: Query): bool =
q.fromUser.len == 0 and (q.filters.len > 0 or q.excludes.len > 0 or
@[q.near, q.until, q.since].anyIt(it.len > 0))
proc renderSearchPanel*(query: Query): VNode =
let user = query.fromUser.join(",")
let action = if user.len > 0: &"/{user}/search" else: "/search"
buildHtml(form(`method`="get", action=action,
class="search-field", autocomplete="off")):
hiddenField("f", "tweets")
genInput("q", "", query.text, "Enter search...", class="pref-inline")
button(`type`="submit"): icon "search"
input(id="search-panel-toggle", `type`="checkbox", checked=isPanelOpen(query))
label(`for`="search-panel-toggle"): icon "down"
tdiv(class="search-panel"):
for f in @["filter", "exclude"]:
span(class="search-title"): text capitalize(f)
tdiv(class="search-toggles"):
for k, v in toggles:
let state =
if f == "filter": k in query.filters
else: k in query.excludes
genCheckbox(&"{f[0]}-{k}", v, state)
tdiv(class="search-row"):
tdiv:
span(class="search-title"): text "Time range"
tdiv(class="date-range"):
genDate("since", query.since)
span(class="search-title"): text "-"
genDate("until", query.until)
tdiv:
span(class="search-title"): text "Near"
genInput("near", "", query.near, "Location...", autofocus=false)
proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string; proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string;
pinned=none(Tweet)): VNode = pinned=none(Tweet)): VNode =
let query = results.query let query = results.query
@@ -100,21 +43,14 @@ proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string;
renderProfileTabs(query, query.fromUser.join(",")) renderProfileTabs(query, query.fromUser.join(","))
if query.fromUser.len == 0 or query.kind == tweets: if query.fromUser.len == 0 or query.kind == tweets:
tdiv(class="timeline-header"): discard
renderSearchPanel(query)
if query.fromUser.len == 0: if query.fromUser.len == 0:
renderSearchTabs(query) renderSearchTabs(query)
renderTimelineTweets(results, prefs, path, pinned) renderTimelineTweets(results, prefs, path, pinned)
proc renderUserSearch*(results: Result[User]; prefs: Prefs): VNode = proc renderUserSearch*(results: Result[User]; prefs: Prefs; path: string): VNode =
buildHtml(tdiv(class="timeline-container")): buildHtml(tdiv(class="timeline-container")):
tdiv(class="timeline-header"):
form(`method`="get", action="/search", class="search-field", autocomplete="off"):
hiddenField("f", "users")
genInput("q", "", results.query.text, "Enter username...", class="pref-inline")
button(`type`="submit"): icon "search"
renderSearchTabs(results.query) renderSearchTabs(results.query)
renderTimelineUsers(results, prefs) renderTimelineUsers(results, prefs, path)

View File

@@ -0,0 +1,58 @@
# SPDX-License-Identifier: AGPL-3.0-only
import strutils, strformat, sequtils, unicode, tables
import karax/[karaxdsl, vdom]
import renderutils
import ".."/[types]
const toggles = {
"nativeretweets": "Retweets",
"media": "Media",
"videos": "Videos",
"news": "News",
"verified": "Verified",
"native_video": "Native videos",
"replies": "Replies",
"links": "Links",
"images": "Images",
"safe": "Safe",
"quote": "Quotes",
"pro_video": "Pro videos"
}.toOrderedTable
proc isPanelOpen(q: Query): bool =
q.fromUser.len == 0 and (q.filters.len > 0 or q.excludes.anyIt(it != "nativeretweets") or
@[q.near, q.until, q.since].anyIt(it.len > 0))
proc renderSearchPanel*(query: Query): VNode =
let user = query.fromUser.join(",")
let action = if user.len > 0: &"/{user}/search" else: "/search"
buildHtml(form(`method`="get", action=action,
class="search-field", autocomplete="off")):
hiddenField("f", "tweets")
genInput("q", "", query.text, "Enter search...", class="pref-inline")
button(`type`="submit"): icon "search"
input(id="search-panel-toggle", `type`="checkbox", checked=isPanelOpen(query))
label(`for`="search-panel-toggle"): icon "down"
tdiv(class="search-panel"):
for f in @["filter", "exclude"]:
span(class="search-title"): text capitalize(f)
tdiv(class="search-toggles"):
for k, v in toggles:
let state =
if f == "filter": k in query.filters
else: k in query.excludes
genCheckbox(&"{f[0]}-{k}", v, state)
tdiv(class="search-row"):
tdiv:
span(class="search-title"): text "Time range"
tdiv(class="date-range"):
genDate("since", query.since)
span(class="search-title"): text "-"
genDate("until", query.until)
tdiv:
span(class="search-title"): text "Near"
genInput("near", "", query.near, "Location...", autofocus=false)

View File

@@ -55,7 +55,7 @@ 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 renderUser(user: User; prefs: Prefs; path: string): VNode =
let class = if user.sensitive: "timeline-item nsfw" else: "timeline-item" let class = if user.sensitive: "timeline-item nsfw" else: "timeline-item"
buildHtml(tdiv(class=class)): buildHtml(tdiv(class=class)):
a(class="tweet-link", href=("/" & user.username)) a(class="tweet-link", href=("/" & user.username))
@@ -67,6 +67,7 @@ proc renderUser(user: User; prefs: Prefs): VNode =
tdiv(class="tweet-name-row"): tdiv(class="tweet-name-row"):
tdiv(class="fullname-and-username"): tdiv(class="fullname-and-username"):
linkUser(user, class="fullname") linkUser(user, class="fullname")
renderFollowButton(user, prefs, path)
linkUser(user, class="username") linkUser(user, class="username")
tdiv(class="tweet-content media-body", dir="auto"): tdiv(class="tweet-content media-body", dir="auto"):
@@ -79,7 +80,7 @@ proc renderTimelineUsers*(results: Result[User]; prefs: Prefs; path=""): VNode =
if results.content.len > 0: if results.content.len > 0:
for user in results.content: for user in results.content:
renderUser(user, prefs) renderUser(user, prefs, path)
if results.bottom.len > 0: if results.bottom.len > 0:
renderMore(results.query, results.bottom) renderMore(results.query, results.bottom)
renderToTop() renderToTop()