diff --git a/src/api.nim b/src/api.nim
index 3b45b0f..df6d586 100644
--- a/src/api.nim
+++ b/src/api.nim
@@ -1,2 +1,2 @@
-import api/[profile, timeline, tweet, search, media]
-export profile, timeline, tweet, search, media
+import api/[profile, timeline, tweet, search, media, list]
+export profile, timeline, tweet, search, media, list
diff --git a/src/api/consts.nim b/src/api/consts.nim
index 0be86d3..61b7508 100644
--- a/src/api/consts.nim
+++ b/src/api/consts.nim
@@ -11,6 +11,8 @@ const
timelineUrl* = "i/profiles/show/$1/timeline/tweets"
timelineMediaUrl* = "i/profiles/show/$1/media_timeline"
+ listUrl* = "$1/lists/$2/timeline"
+ listMembersUrl* = "$1/lists/$2/members"
profilePopupUrl* = "i/profiles/popup"
profileIntentUrl* = "intent/user"
searchUrl* = "i/search/timeline"
diff --git a/src/api/list.nim b/src/api/list.nim
new file mode 100644
index 0000000..249ac5c
--- /dev/null
+++ b/src/api/list.nim
@@ -0,0 +1,83 @@
+import httpclient, asyncdispatch, htmlparser, strformat
+import sequtils, strutils, json, uri
+
+import ".."/[types, parser, parserutils, query]
+import utils, consts, timeline, search
+
+proc getListTimeline*(username, list, agent, after: string): Future[Timeline] {.async.} =
+ let url = base / (listUrl % [username, list])
+
+ let headers = newHttpHeaders({
+ "Accept": jsonAccept,
+ "Referer": $url,
+ "User-Agent": agent,
+ "X-Twitter-Active-User": "yes",
+ "X-Requested-With": "XMLHttpRequest",
+ "Accept-Language": lang
+ })
+
+ var params = toSeq({
+ "include_available_features": "1",
+ "include_entities": "1",
+ "reset_error_state": "false"
+ })
+
+ if after.len > 0:
+ params.add {"max_position": after}
+
+ let json = await fetchJson(url ? params, headers)
+ result = await finishTimeline(json, Query(), after, agent)
+ if result.content.len > 0:
+ result.minId = result.content[^1].id
+
+proc getListMembers*(username, list, agent: string): Future[Result[Profile]] {.async.} =
+ let url = base / (listMembersUrl % [username, list])
+
+ let headers = newHttpHeaders({
+ "Accept": htmlAccept,
+ "Referer": $(base / &"{username}/lists/{list}/members"),
+ "User-Agent": agent,
+ "Accept-Language": lang
+ })
+
+ let html = await fetchHtml(url, headers)
+
+ result = Result[Profile](
+ minId: html.selectAttr(".stream-container", "data-min-position"),
+ hasMore: html.select(".has-more-items") != nil,
+ beginning: true,
+ query: Query(kind: users),
+ content: html.selectAll(".account").map(parseListProfile)
+ )
+
+proc getListMembersSearch*(username, list, agent, after: string): Future[Result[Profile]] {.async.} =
+ let url = base / ((listMembersUrl & "/timeline") % [username, list])
+
+ let headers = newHttpHeaders({
+ "Accept": jsonAccept,
+ "Referer": $(base / &"{username}/lists/{list}/members"),
+ "User-Agent": agent,
+ "X-Twitter-Active-User": "yes",
+ "X-Requested-With": "XMLHttpRequest",
+ "X-Push-With": "XMLHttpRequest",
+ "Accept-Language": lang
+ })
+
+ var params = toSeq({
+ "include_available_features": "1",
+ "include_entities": "1",
+ "reset_error_state": "false"
+ })
+
+ if after.len > 0:
+ params.add {"max_position": after}
+
+ let json = await fetchJson(url ? params, headers)
+
+ result = getResult[Profile](json, Query(kind: users), after)
+ if json == nil or not json.hasKey("items_html"): return
+
+ let html = json["items_html"].to(string)
+ result.hasMore = html != "\n"
+ for p in parseHtml(html).selectAll(".account"):
+ result.content.add parseListProfile(p)
diff --git a/src/api/search.nim b/src/api/search.nim
index 480df1b..36e7a41 100644
--- a/src/api/search.nim
+++ b/src/api/search.nim
@@ -7,7 +7,7 @@ import utils, consts, timeline
proc getResult*[T](json: JsonNode; query: Query; after: string): Result[T] =
if json == nil: return Result[T](beginning: true, query: query)
Result[T](
- hasMore: json["has_more_items"].to(bool),
+ hasMore: json.getOrDefault("has_more_items").getBool(false),
maxId: json.getOrDefault("max_position").getStr(""),
minId: json.getOrDefault("min_position").getStr("").cleanPos(),
query: query,
@@ -16,7 +16,7 @@ proc getResult*[T](json: JsonNode; query: Query; after: string): Result[T] =
proc getSearch*[T](query: Query; after, agent: string): Future[Result[T]] {.async.} =
let
- kind = if query.kind == users: "users" else: "tweets"
+ kind = if query.kind == userSearch: "users" else: "tweets"
pos = when T is Tweet: genPos(after) else: after
param = genQueryParam(query)
@@ -46,10 +46,9 @@ proc getSearch*[T](query: Query; after, agent: string): Future[Result[T]] {.asyn
return Result[T](query: query, beginning: true)
let json = await fetchJson(base / searchUrl ? params, headers)
- if json == nil: return Result[T](query: query, beginning: true)
result = getResult[T](json, query, after)
- if not json.hasKey("items_html"): return
+ if json == nil or not json.hasKey("items_html"): return
when T is Tweet:
result = await finishTimeline(json, query, after, agent)
diff --git a/src/api/timeline.nim b/src/api/timeline.nim
index ada0c67..b2a98d1 100644
--- a/src/api/timeline.nim
+++ b/src/api/timeline.nim
@@ -1,4 +1,4 @@
-import httpclient, asyncdispatch, htmlparser
+import httpclient, asyncdispatch, htmlparser, strformat
import sequtils, strutils, json, xmltree, uri
import ".."/[types, parser, parserutils, formatters, query]
diff --git a/src/nitter.nim b/src/nitter.nim
index cece195..543da28 100644
--- a/src/nitter.nim
+++ b/src/nitter.nim
@@ -5,13 +5,14 @@ import jester
import types, config, prefs
import views/[general, about]
-import routes/[preferences, timeline, status, media, search, rss]
+import routes/[preferences, timeline, status, media, search, rss, list]
const configPath {.strdefine.} = "./nitter.conf"
let cfg = getConfig(configPath)
createPrefRouter(cfg)
createTimelineRouter(cfg)
+createListRouter(cfg)
createStatusRouter(cfg)
createSearchRouter(cfg)
createMediaRouter(cfg)
@@ -24,15 +25,16 @@ settings:
routes:
get "/":
- resp renderMain(renderSearch(), Prefs(), cfg.title)
+ resp renderMain(renderSearch(), request, cfg.title)
get "/about":
- resp renderMain(renderAbout(), Prefs(), cfg.title)
+ resp renderMain(renderAbout(), request, cfg.title)
extend preferences, ""
extend rss, ""
extend search, ""
extend timeline, ""
+ extend list, ""
extend status, ""
extend media, ""
diff --git a/src/parser.nim b/src/parser.nim
index f59ce2d..22413eb 100644
--- a/src/parser.nim
+++ b/src/parser.nim
@@ -39,6 +39,16 @@ proc parsePopupProfile*(node: XmlNode; selector=".profile-card"): Profile =
result.getPopupStats(profile)
+proc parseListProfile*(profile: XmlNode): Profile =
+ result = Profile(
+ fullname: profile.getName(".fullname"),
+ username: profile.getUsername(".username"),
+ bio: profile.getBio(".bio"),
+ userpic: profile.getAvatar(".avatar"),
+ verified: isVerified(profile),
+ protected: isProtected(profile),
+ )
+
proc parseIntentProfile*(profile: XmlNode): Profile =
result = Profile(
fullname: profile.getName("a.fn.url.alternate-context"),
diff --git a/src/query.nim b/src/query.nim
index 402a194..5561169 100644
--- a/src/query.nim
+++ b/src/query.nim
@@ -58,7 +58,7 @@ proc genQueryParam*(query: Query): string =
var filters: seq[string]
var param: string
- if query.kind == users:
+ if query.kind == userSearch:
return query.text
for i, user in query.fromUser:
@@ -84,7 +84,7 @@ proc genQueryParam*(query: Query): string =
result &= " " & query.text
proc genQueryUrl*(query: Query): string =
- if query.kind notin {custom, users}: return
+ if query.kind notin {custom, userSearch}: return
var params = @[&"kind={query.kind}"]
if query.text.len > 0:
diff --git a/src/routes/list.nim b/src/routes/list.nim
new file mode 100644
index 0000000..312e56c
--- /dev/null
+++ b/src/routes/list.nim
@@ -0,0 +1,34 @@
+import strutils
+
+import jester
+
+import router_utils
+import ".."/[query, types, api, agents]
+import ../views/[general, timeline, list]
+
+template respList*(list, timeline: typed) =
+ if list.minId.len == 0:
+ resp Http404, showError("List \"" & @"list" & "\" not found", cfg.title)
+ let html = renderList(timeline, list.query, @"name", @"list")
+ let rss = "/$1/lists/$2/rss" % [@"name", @"list"]
+ resp renderMain(html, request, cfg.title, rss=rss)
+
+proc createListRouter*(cfg: Config) =
+ router list:
+ get "/@name/lists/@list":
+ cond '.' notin @"name"
+ let
+ list = await getListTimeline(@"name", @"list", getAgent(), @"after")
+ tweets = renderTimelineTweets(list, cookiePrefs(), request.path)
+ respList list, tweets
+
+ get "/@name/lists/@list/members":
+ cond '.' notin @"name"
+ let list =
+ if @"after".len == 0:
+ await getListMembers(@"name", @"list", getAgent())
+ else:
+ await getListMembersSearch(@"name", @"list", getAgent(), @"after")
+
+ let users = renderTimelineUsers(list, cookiePrefs(), request.path)
+ respList list, users
diff --git a/src/routes/media.nim b/src/routes/media.nim
index 1fa1915..f187853 100644
--- a/src/routes/media.nim
+++ b/src/routes/media.nim
@@ -3,7 +3,7 @@ import asyncfile, uri, strutils, httpclient, os
import jester, regex
import router_utils
-import ".."/[types, formatters, prefs]
+import ".."/[types, formatters]
import ../views/general
export asyncfile, httpclient, os, strutils
diff --git a/src/routes/preferences.nim b/src/routes/preferences.nim
index 4058348..d824771 100644
--- a/src/routes/preferences.nim
+++ b/src/routes/preferences.nim
@@ -3,7 +3,7 @@ import strutils, uri
import jester
import router_utils
-import ".."/[prefs, types]
+import ".."/[types]
import ../views/[general, preferences]
export preferences
diff --git a/src/routes/router_utils.nim b/src/routes/router_utils.nim
index 164a28b..391ba6a 100644
--- a/src/routes/router_utils.nim
+++ b/src/routes/router_utils.nim
@@ -1,5 +1,5 @@
-import ../utils
-export utils
+import ../utils, ../prefs
+export utils, prefs
template cookiePrefs*(): untyped {.dirty.} =
getPrefs(request.cookies.getOrDefault("preferences"))
diff --git a/src/routes/rss.nim b/src/routes/rss.nim
index fa4acb8..f40d939 100644
--- a/src/routes/rss.nim
+++ b/src/routes/rss.nim
@@ -34,3 +34,8 @@ proc createRssRouter*(cfg: Config) =
get "/@name/search/rss":
cond '.' notin @"name"
respRss(await showRss(@"name", initQuery(params(request), name=(@"name"))))
+
+ get "/@name/lists/@list/rss":
+ cond '.' notin @"name"
+ let list = await getListTimeline(@"name", @"list", getAgent(), "")
+ respRss(renderListRss(list.content, @"name", @"list"))
diff --git a/src/routes/search.nim b/src/routes/search.nim
index 700ca69..89497cd 100644
--- a/src/routes/search.nim
+++ b/src/routes/search.nim
@@ -3,7 +3,7 @@ import strutils, sequtils, uri
import jester
import router_utils
-import ".."/[query, types, api, agents, prefs]
+import ".."/[query, types, api, agents]
import ../views/[general, search]
export search
@@ -18,7 +18,7 @@ proc createSearchRouter*(cfg: Config) =
let query = initQuery(params(request))
case query.kind
- of users:
+ of userSearch:
if "," in @"text":
redirect("/" & @"text")
let users = await getSearch[Profile](query, @"after", getAgent())
diff --git a/src/routes/status.nim b/src/routes/status.nim
index 2b37367..bfe0ec3 100644
--- a/src/routes/status.nim
+++ b/src/routes/status.nim
@@ -3,7 +3,7 @@ import asyncdispatch, strutils, sequtils, uri
import jester
import router_utils
-import ".."/[api, prefs, types, formatters, agents]
+import ".."/[api, types, formatters, agents]
import ../views/[general, status]
export uri, sequtils
diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim
index 4448913..2de73a1 100644
--- a/src/routes/timeline.nim
+++ b/src/routes/timeline.nim
@@ -3,7 +3,7 @@ import asyncdispatch, strutils, sequtils, uri
import jester
import router_utils
-import ".."/[api, prefs, types, cache, formatters, agents, query]
+import ".."/[api, types, cache, formatters, agents, query]
import ../views/[general, profile, timeline, status, search]
export uri, sequtils
diff --git a/src/sass/tweet/_base.scss b/src/sass/tweet/_base.scss
index a1320fd..1afc9e2 100644
--- a/src/sass/tweet/_base.scss
+++ b/src/sass/tweet/_base.scss
@@ -85,7 +85,10 @@
.replying-to {
color: $fg_dark;
margin: -2px 0 4px;
- pointer-events: all;
+
+ a {
+ pointer-events: all;
+ }
}
.retweet, .pinned, .tweet-stats {
diff --git a/src/types.nim b/src/types.nim
index be3ee20..3e431b9 100644
--- a/src/types.nim
+++ b/src/types.nim
@@ -57,7 +57,7 @@ dbFromTypes("cache.db", "", "", "", [Profile, Video])
type
QueryKind* = enum
- posts, replies, media, users, custom
+ posts, replies, media, users, userSearch, custom
Query* = object
kind*: QueryKind
diff --git a/src/utils.nim b/src/utils.nim
index a65e9cb..d49752b 100644
--- a/src/utils.nim
+++ b/src/utils.nim
@@ -41,7 +41,7 @@ proc cleanFilename*(filename: string): string =
filename.replace(reg, "_")
proc filterParams*(params: Table): seq[(string, string)] =
- let filter = ["name", "id"]
+ let filter = ["name", "id", "list"]
toSeq(params.pairs()).filterIt(it[0] notin filter and it[1].len > 0)
proc isTwitterUrl*(url: string): bool =
diff --git a/src/views/general.nim b/src/views/general.nim
index fad9c85..f424ffc 100644
--- a/src/views/general.nim
+++ b/src/views/general.nim
@@ -71,5 +71,5 @@ proc renderError*(error: string): VNode =
tdiv(class="error-panel"):
span: text error
-proc showError*(error, title: string): string =
- renderMain(renderError(error), Request(), title, "Error")
+template showError*(error, title: string): string =
+ renderMain(renderError(error), request, title, "Error")
diff --git a/src/views/list.nim b/src/views/list.nim
new file mode 100644
index 0000000..8020423
--- /dev/null
+++ b/src/views/list.nim
@@ -0,0 +1,20 @@
+import strformat
+import karax/[karaxdsl, vdom]
+
+import renderutils
+import ".."/[types]
+
+proc renderListTabs*(query: Query; path: string): VNode =
+ buildHtml(ul(class="tab")):
+ li(class=query.getTabClass(posts)):
+ a(href=(path)): text "Tweets"
+ li(class=query.getTabClass(users)):
+ a(href=(path & "/members")): text "Members"
+
+proc renderList*(body: VNode; query: Query; name, list: string): VNode =
+ buildHtml(tdiv(class="timeline-container")):
+ tdiv(class="timeline-header"):
+ text &"\"{list}\" by @{name}"
+
+ renderListTabs(query, &"/{name}/lists/{list}")
+ body
diff --git a/src/views/renderutils.nim b/src/views/renderutils.nim
index 60445dd..7612acb 100644
--- a/src/views/renderutils.nim
+++ b/src/views/renderutils.nim
@@ -30,10 +30,6 @@ proc linkUser*(profile: Profile, class=""): VNode =
text " "
icon "lock-circled", title="Protected account"
-proc genImg*(url: string; class=""): VNode =
- buildHtml():
- img(src=getPicUrl(url), class=class, alt="Image")
-
proc linkText*(text: string; class=""): VNode =
let url = if "http" notin text: "http://" & text else: text
buildHtml():
@@ -91,3 +87,12 @@ proc genDate*(pref, state: string): VNode =
else:
verbatim &""
icon "calendar"
+
+proc genImg*(url: string; class=""): VNode =
+ buildHtml():
+ img(src=getPicUrl(url), class=class, alt="Image")
+
+proc getTabClass*(query: Query; tab: QueryKind): string =
+ result = "tab-item"
+ if query.kind == tab:
+ result &= " active"
diff --git a/src/views/rss.nimf b/src/views/rss.nimf
index f9879c7..ac8bfc4 100644
--- a/src/views/rss.nimf
+++ b/src/views/rss.nimf
@@ -71,3 +71,29 @@
#end proc
+#
+#proc renderListRss*(tweets: seq[Tweet]; name, list: string): string =
+#let prefs = Prefs(replaceTwitter: hostname)
+#result = ""
+
+
+
+
+ ${list} / @${name}
+ https://${hostname}/${name}/lists/${list}
+ Twitter feed for: ${list} by @${name}. Generated by ${hostname}
+ en-us
+ 40
+ #for tweet in tweets:
+ -
+ ${getTitle(tweet, prefs)}
+ @${tweet.profile.username}
+
+ ${getRfc822Time(tweet)}
+ https://${hostname}${getLink(tweet)}
+ https://${hostname}${getLink(tweet)}
+
+ #end for
+
+
+#end proc
diff --git a/src/views/search.nim b/src/views/search.nim
index 9b60b38..319c9bc 100644
--- a/src/views/search.nim
+++ b/src/views/search.nim
@@ -23,15 +23,10 @@ proc renderSearch*(): VNode =
buildHtml(tdiv(class="panel-container")):
tdiv(class="search-bar"):
form(`method`="get", action="/search"):
- hiddenField("kind", "users")
+ hiddenField("kind", "userSearch")
input(`type`="text", name="text", autofocus="", placeholder="Enter username...")
button(`type`="submit"): icon "search"
-proc getTabClass(query: Query; tab: QueryKind): string =
- result = "tab-item"
- if query.kind == tab:
- result &= " active"
-
proc renderProfileTabs*(query: Query; username: string): VNode =
let link = "/" & username
buildHtml(ul(class="tab")):
@@ -50,8 +45,8 @@ proc renderSearchTabs*(query: Query): VNode =
li(class=query.getTabClass(custom)):
q.kind = custom
a(href=("?" & genQueryUrl(q))): text "Tweets"
- li(class=query.getTabClass(users)):
- q.kind = users
+ li(class=query.getTabClass(userSearch)):
+ q.kind = userSearch
a(href=("?" & genQueryUrl(q))): text "Users"
proc isPanelOpen(q: Query): bool =
@@ -114,7 +109,7 @@ proc renderUserSearch*(users: Result[Profile]; prefs: Prefs): VNode =
buildHtml(tdiv(class="timeline-container")):
tdiv(class="timeline-header"):
form(`method`="get", action="/search", class="search-field"):
- hiddenField("kind", "users")
+ hiddenField("kind", "userSearch")
genInput("text", "", users.query.text, "Enter username...", class="pref-inline")
button(`type`="submit"): icon "search"
diff --git a/src/views/timeline.nim b/src/views/timeline.nim
index 0fde744..a4072b9 100644
--- a/src/views/timeline.nim
+++ b/src/views/timeline.nim
@@ -64,7 +64,8 @@ proc renderTimelineUsers*(results: Result[Profile]; prefs: Prefs; path=""): VNod
if results.content.len > 0:
for user in results.content:
renderUser(user, prefs)
- renderMore(results.query, results.minId)
+ if results.minId != "0":
+ renderMore(results.query, results.minId)
elif results.beginning:
renderNoneFound()
else: