diff --git a/README.md b/README.md
index 9e8231b..62ad1f0 100644
--- a/README.md
+++ b/README.md
@@ -65,8 +65,6 @@ Then enable and run the service:
## Todo (roughly in this order)
-- Search (images/videos, hashtags, etc.)
-- Custom timeline filter
- More caching (waiting for [moigagoo/norm#19](https://github.com/moigagoo/norm/pull/19))
- Simple account system with customizable feed
- Json API endpoints
diff --git a/screenshot.png b/screenshot.png
index 800c321..c81beaa 100644
Binary files a/screenshot.png and b/screenshot.png differ
diff --git a/src/api.nim b/src/api.nim
index 2cffc66..3b45b0f 100644
--- a/src/api.nim
+++ b/src/api.nim
@@ -1,2 +1,2 @@
-import api/[media, profile, timeline, tweet, search]
+import api/[profile, timeline, tweet, search, media]
export profile, timeline, tweet, search, media
diff --git a/src/api/media.nim b/src/api/media.nim
index 2b3b667..03c1a23 100644
--- a/src/api/media.nim
+++ b/src/api/media.nim
@@ -89,10 +89,10 @@ proc getVideoFetch(tweet: Tweet; agent, token: string) {.async.} =
return
if tweet.card.isNone:
- tweet.video = some(parseVideo(json, tweet.id))
+ tweet.video = some parseVideo(json, tweet.id)
else:
- get(tweet.card).video = some(parseVideo(json, tweet.id))
- tweet.video = none(Video)
+ get(tweet.card).video = some parseVideo(json, tweet.id)
+ tweet.video = none Video
tokenUses.inc
proc getVideoVar(tweet: Tweet): var Option[Video] =
@@ -104,7 +104,7 @@ proc getVideoVar(tweet: Tweet): var Option[Video] =
proc getVideo*(tweet: Tweet; agent, token: string; force=false) {.async.} =
withCustomDb("cache.db", "", "", ""):
try:
- getVideoVar(tweet) = some(Video.getOne("videoId = ?", tweet.id))
+ getVideoVar(tweet) = some Video.getOne("videoId = ?", tweet.id)
except KeyError:
await getVideoFetch(tweet, agent, token)
var video = getVideoVar(tweet)
@@ -126,7 +126,7 @@ proc getPoll*(tweet: Tweet; agent: string) {.async.} =
let html = await fetchHtml(url, headers)
if html == nil: return
- tweet.poll = some(parsePoll(html))
+ tweet.poll = some parsePoll(html)
proc getCard*(tweet: Tweet; agent: string) {.async.} =
if tweet.card.isNone(): return
diff --git a/src/api/search.nim b/src/api/search.nim
index f07a864..979ca25 100644
--- a/src/api/search.nim
+++ b/src/api/search.nim
@@ -1,32 +1,56 @@
import httpclient, asyncdispatch, htmlparser
-import sequtils, strutils, json, xmltree, uri
+import strutils, json, xmltree, uri
-import ".."/[types, parser, parserutils, formatters, search]
-import utils, consts, media, timeline
+import ".."/[types, parser, parserutils, query]
+import utils, consts, timeline
-proc getTimelineSearch*(query: Query; after, agent: string): Future[Timeline] {.async.} =
- let queryParam = genQueryParam(query)
- let queryEncoded = encodeUrl(queryParam, usePlus=false)
+proc getResult[T](json: JsonNode; query: Query; after: string): Result[T] =
+ Result[T](
+ hasMore: json["has_more_items"].to(bool),
+ maxId: json.getOrDefault("max_position").getStr(""),
+ minId: json.getOrDefault("min_position").getStr("").cleanPos(),
+ query: query,
+ beginning: after.len == 0
+ )
- let headers = newHttpHeaders({
- "Accept": jsonAccept,
- "Referer": $(base / ("search?f=tweets&vertical=default&q=$1&src=typd" % queryEncoded)),
- "User-Agent": agent,
- "X-Requested-With": "XMLHttpRequest",
- "Authority": "twitter.com",
- "Accept-Language": lang
- })
+proc getSearch*[T](query: Query; after, agent: string): Future[Result[T]] {.async.} =
+ let
+ kind = if query.kind == users: "users" else: "tweets"
+ pos = when T is Tweet: genPos(after) else: after
- let params = {
- "f": "tweets",
- "vertical": "default",
- "q": queryParam,
- "src": "typd",
- "include_available_features": "1",
- "include_entities": "1",
- "max_position": if after.len > 0: genPos(after) else: "0",
- "reset_error_state": "false"
- }
+ param = genQueryParam(query)
+ encoded = encodeUrl(param, usePlus=false)
+
+ headers = newHttpHeaders({
+ "Accept": jsonAccept,
+ "Referer": $(base / ("search?f=$1&q=$2&src=typd" % [kind, encoded])),
+ "User-Agent": agent,
+ "X-Requested-With": "XMLHttpRequest",
+ "Authority": "twitter.com",
+ "Accept-Language": lang
+ })
+
+ params = {
+ "f": kind,
+ "vertical": "default",
+ "q": param,
+ "src": "typd",
+ "include_available_features": "1",
+ "include_entities": "1",
+ "max_position": if pos.len > 0: pos else: "0",
+ "reset_error_state": "false"
+ }
let json = await fetchJson(base / searchUrl ? params, headers)
- result = await finishTimeline(json, some(query), after, agent)
+ if json == nil: return Result[T](query: query, beginning: true)
+
+ result = getResult[T](json, query, after)
+ if not json.hasKey("items_html"): return
+ let html = parseHtml(json["items_html"].to(string))
+
+ when T is Tweet:
+ result = await finishTimeline(json, query, after, agent)
+ elif T is Profile:
+ result.hasMore = json["items_html"].to(string) != "\n"
+ for p in html.selectAll(".js-stream-item"):
+ result.content.add parsePopupProfile(p, ".ProfileCard")
diff --git a/src/api/timeline.nim b/src/api/timeline.nim
index e917152..8687bdf 100644
--- a/src/api/timeline.nim
+++ b/src/api/timeline.nim
@@ -1,11 +1,11 @@
import httpclient, asyncdispatch, htmlparser
import sequtils, strutils, json, xmltree, uri
-import ".."/[types, parser, parserutils, formatters, search]
+import ".."/[types, parser, parserutils, formatters, query]
import utils, consts, media
-proc finishTimeline*(json: JsonNode; query: Option[Query]; after, agent: string): Future[Timeline] {.async.} =
- if json == nil: return Timeline()
+proc finishTimeline*(json: JsonNode; query: Query; after, agent: string): Future[Timeline] {.async.} =
+ if json == nil: return Timeline(beginning: true, query: query)
result = Timeline(
hasMore: json["has_more_items"].to(bool),
@@ -49,7 +49,7 @@ proc getTimeline*(username, after, agent: string): Future[Timeline] {.async.} =
params.add {"max_position": after}
let json = await fetchJson(base / (timelineUrl % username) ? params, headers)
- result = await finishTimeline(json, none(Query), after, agent)
+ result = await finishTimeline(json, Query(), after, agent)
proc getProfileAndTimeline*(username, agent, after: string): Future[(Profile, Timeline)] {.async.} =
let headers = newHttpHeaders({
diff --git a/src/cache.nim b/src/cache.nim
index ec4c1bd..f02b568 100644
--- a/src/cache.nim
+++ b/src/cache.nim
@@ -29,9 +29,9 @@ proc hasCachedProfile*(username: string): Option[Profile] =
try:
let p = Profile.getOne("lower(username) = ?", toLower(username))
doAssert not p.isOutdated
- result = some(p)
+ result = some p
except AssertionError, KeyError:
- result = none(Profile)
+ result = none Profile
proc getCachedProfile*(username, agent: string; force=false): Future[Profile] {.async.} =
withDb:
diff --git a/src/formatters.nim b/src/formatters.nim
index 42ce2dd..fdd3a8d 100644
--- a/src/formatters.nim
+++ b/src/formatters.nim
@@ -1,4 +1,4 @@
-import strutils, strformat, htmlgen, xmltree, times
+import strutils, strformat, sequtils, htmlgen, xmltree, times, uri
import regex
import types, utils
@@ -8,9 +8,10 @@ from unicode import Rune, `$`
const
urlRegex = re"((https?|ftp)://(-\.)?([^\s/?\.#]+\.?)+([/\?][^\s\)]*)?)"
emailRegex = re"([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)"
- usernameRegex = re"(^|[^A-z0-9_?])@([A-z0-9_]+)"
+ usernameRegex = re"(^|[^A-z0-9_?\/])@([A-z0-9_]+)"
picRegex = re"pic.twitter.com/[^ ]+"
ellipsisRegex = re" ?…"
+ hashtagRegex = re"([^\S])?([#$][A-z0-9]+)"
ytRegex = re"(www.|m.)?youtu(be.com|.be)"
twRegex = re"(www.|mobile.)?twitter.com"
nbsp = $Rune(0x000A0)
@@ -40,6 +41,15 @@ proc reEmailToLink*(m: RegexMatch; s: string): string =
let url = s[m.group(0)[0]]
toLink("mailto://" & url, url)
+proc reHashtagToLink*(m: RegexMatch; s: string): string =
+ result = if m.group(0).len > 0: s[m.group(0)[0]] else: ""
+ let hash = s[m.group(1)[0]]
+ let link = toLink("/search?text=" & encodeUrl(hash), hash)
+ if hash.any(isAlphaAscii):
+ result &= link
+ else:
+ result &= hash
+
proc reUsernameToLink*(m: RegexMatch; s: string): string =
var username = ""
var pretext = ""
@@ -67,7 +77,7 @@ proc replaceUrl*(url: string; prefs: Prefs): string =
proc linkifyText*(text: string; prefs: Prefs; rss=false): string =
result = xmltree.escape(stripText(text))
- result = result.replace(ellipsisRegex, "")
+ result = result.replace(ellipsisRegex, " ")
result = result.replace(emailRegex, reEmailToLink)
if rss:
result = result.replace(urlRegex, reUrlToLink)
@@ -75,6 +85,7 @@ proc linkifyText*(text: string; prefs: Prefs; rss=false): string =
else:
result = result.replace(urlRegex, reUrlToShortLink)
result = result.replace(usernameRegex, reUsernameToLink)
+ result = result.replace(hashtagRegex, reHashtagToLink)
result = result.replace(re"([^\s\(\n%])\s+([;.,!\)'%]|')", "$1")
result = result.replace(re"^\. a > b")
if by.len > 0:
- result.retweet = some(Retweet(
+ result.retweet = some Retweet(
by: stripText(by),
id: tweet.attr("data-retweet-id")
- ))
+ )
let quote = tweet.select(".QuoteTweet-innerContainer")
if quote != nil:
- result.quote = some(parseQuote(quote))
+ result.quote = some parseQuote(quote)
let tombstone = tweet.select(".Tombstone")
if tombstone != nil:
if "unavailable" in tombstone.innerText():
let quote = Quote(tombstone: getTombstone(node.selectText(".Tombstone-label")))
- result.quote = some(quote)
+ result.quote = some quote
proc parseThread*(nodes: XmlNode): Thread =
if nodes == nil: return
@@ -157,7 +157,7 @@ proc parseConversation*(node: XmlNode): Conversation =
result.replies.add parseThread(thread)
proc parseTimeline*(node: XmlNode; after: string): Timeline =
- if node == nil: return
+ if node == nil: return Timeline()
result = Timeline(
content: parseThread(node.select(".stream > .stream-items")).content,
minId: node.attr("data-min-position"),
@@ -234,7 +234,7 @@ proc parseCard*(card: var Card; node: XmlNode) =
let image = node.select(".tcu-imageWrapper img")
if image != nil:
# workaround for issue 11713
- card.image = some(image.attr("data-src").replace("gname", "g&name"))
+ card.image = some image.attr("data-src").replace("gname", "g&name")
if card.kind == liveEvent:
card.text = card.title
diff --git a/src/parserutils.nim b/src/parserutils.nim
index 6de7bdf..53be3c9 100644
--- a/src/parserutils.nim
+++ b/src/parserutils.nim
@@ -86,8 +86,11 @@ proc getName*(profile: XmlNode; selector: string): string =
proc getUsername*(profile: XmlNode; selector: string): string =
profile.selectText(selector).strip(chars={'@', ' ', '\n'})
-proc getBio*(profile: XmlNode; selector: string): string =
- profile.selectText(selector).stripText()
+proc getBio*(profile: XmlNode; selector: string; fallback=""): string =
+ var bio = profile.selectText(selector)
+ if bio.len == 0 and fallback.len > 0:
+ bio = profile.selectText(fallback)
+ stripText(bio)
proc getAvatar*(profile: XmlNode; selector: string): string =
profile.selectAttr(selector, "src").getUserpic()
@@ -177,9 +180,9 @@ proc getTweetMedia*(tweet: Tweet; node: XmlNode) =
if player == nil: return
if "gif" in player.attr("class"):
- tweet.gif = some(getGif(player.select(".PlayableMedia-player")))
+ tweet.gif = some getGif(player.select(".PlayableMedia-player"))
elif "video" in player.attr("class"):
- tweet.video = some(Video())
+ tweet.video = some Video()
proc getQuoteMedia*(quote: var Quote; node: XmlNode) =
if node.select(".QuoteTweet--sensitive") != nil:
@@ -206,7 +209,7 @@ proc getTweetCard*(tweet: Tweet; node: XmlNode) =
cardType = cardType.split(":")[^1]
if "poll" in cardType:
- tweet.poll = some(Poll())
+ tweet.poll = some Poll()
return
let cardDiv = node.select(".card2 > .js-macaw-cards-iframe-container")
@@ -227,7 +230,7 @@ proc getTweetCard*(tweet: Tweet; node: XmlNode) =
if n.attr("href") == cardUrl:
card.url = n.attr("data-expanded-url")
- tweet.card = some(card)
+ tweet.card = some card
proc getMoreReplies*(node: XmlNode): int =
let text = node.innerText().strip()
diff --git a/src/query.nim b/src/query.nim
new file mode 100644
index 0000000..80a7799
--- /dev/null
+++ b/src/query.nim
@@ -0,0 +1,127 @@
+import strutils, strformat, sequtils, tables, uri
+
+import types
+
+const
+ separators = @["AND", "OR"]
+ validFilters* = @[
+ "media", "images", "twimg", "videos",
+ "native_video", "consumer_video", "pro_video",
+ "links", "news", "quote", "mentions",
+ "replies", "retweets", "nativeretweets",
+ "verified", "safe"
+ ]
+
+# Experimental, this might break in the future
+# Till then, it results in shorter urls
+const
+ posPrefix = "thGAVUV0VFVB"
+ posSuffix = "EjUAFQAlAFUAFQAA"
+
+template `@`(param: string): untyped =
+ if param in pms: pms[param]
+ else: ""
+
+proc initQuery*(pms: Table[string, string]; name=""): Query =
+ result = Query(
+ kind: parseEnum[QueryKind](@"kind", custom),
+ text: @"text",
+ filters: validFilters.filterIt("f-" & it in pms),
+ excludes: validFilters.filterIt("e-" & it in pms),
+ since: @"since",
+ until: @"until",
+ near: @"near"
+ )
+
+ if name.len > 0:
+ result.fromUser = name.split(",")
+
+ if @"e-nativeretweets".len == 0:
+ result.includes.add "nativeretweets"
+
+proc getMediaQuery*(name: string): Query =
+ Query(
+ kind: media,
+ filters: @["twimg", "native_video"],
+ fromUser: @[name],
+ sep: "OR"
+ )
+
+proc getReplyQuery*(name: string): Query =
+ Query(
+ kind: replies,
+ includes: @["nativeretweets"],
+ fromUser: @[name]
+ )
+
+proc genQueryParam*(query: Query): string =
+ var filters: seq[string]
+ var param: string
+
+ if query.kind == users:
+ return query.text
+
+ for i, user in query.fromUser:
+ param &= &"from:{user} "
+ if i < query.fromUser.high:
+ param &= "OR "
+
+ for f in query.filters:
+ filters.add "filter:" & f
+ for e in query.excludes:
+ filters.add "-filter:" & e
+ for i in query.includes:
+ filters.add "include:" & i
+
+ result = strip(param & filters.join(&" {query.sep} "))
+ if query.since.len > 0:
+ result &= " since:" & query.since
+ if query.until.len > 0:
+ result &= " until:" & query.until
+ if query.near.len > 0:
+ result &= &" near:\"{query.near}\" within:15mi"
+ if query.text.len > 0:
+ result &= " " & query.text
+
+proc genQueryUrl*(query: Query; onlyParam=false): string =
+ if query.fromUser.len > 0:
+ result = "/" & query.fromUser.join(",")
+
+ if query.fromUser.len > 1 and query.kind == posts:
+ return result & "?"
+
+ if query.kind notin {custom, users}:
+ return result & &"/{query.kind}?"
+
+ if onlyParam:
+ result = ""
+ else:
+ result &= &"/search?"
+
+ var params = @[&"kind={query.kind}"]
+ if query.text.len > 0:
+ params.add "text=" & encodeUrl(query.text)
+ for f in query.filters:
+ params.add "f-" & f & "=on"
+ for e in query.excludes:
+ params.add "e-" & e & "=on"
+ for i in query.includes:
+ params.add "i-" & i & "=on"
+
+ if query.since.len > 0:
+ params.add "since=" & query.since
+ if query.until.len > 0:
+ params.add "until=" & query.until
+ if query.near.len > 0:
+ params.add "near=" & query.near
+
+ if params.len > 0:
+ result &= params.join("&")
+
+proc cleanPos*(pos: string): string =
+ pos.multiReplace((posPrefix, ""), (posSuffix, ""))
+
+proc genPos*(pos: string): string =
+ result = posPrefix & pos
+ if "A==" notin result:
+ result &= posSuffix
diff --git a/src/routes/rss.nim b/src/routes/rss.nim
index bba16fb..fa4acb8 100644
--- a/src/routes/rss.nim
+++ b/src/routes/rss.nim
@@ -3,12 +3,12 @@ import asyncdispatch, strutils
import jester
import router_utils, timeline
-import ".."/[cache, agents, search]
+import ".."/[cache, agents, query]
import ../views/general
include "../views/rss.nimf"
-proc showRss*(name: string; query: Option[Query]): Future[string] {.async.} =
+proc showRss*(name: string; query: Query): Future[string] {.async.} =
let (profile, timeline, _) = await fetchSingleTimeline(name, "", getAgent(), query)
return renderTimelineRss(timeline.content, profile)
@@ -21,12 +21,16 @@ proc createRssRouter*(cfg: Config) =
router rss:
get "/@name/rss":
cond '.' notin @"name"
- respRss(await showRss(@"name", none(Query)))
+ respRss(await showRss(@"name", Query()))
get "/@name/replies/rss":
cond '.' notin @"name"
- respRss(await showRss(@"name", some(getReplyQuery(@"name"))))
+ respRss(await showRss(@"name", getReplyQuery(@"name")))
get "/@name/media/rss":
cond '.' notin @"name"
- respRss(await showRss(@"name", some(getMediaQuery(@"name"))))
+ respRss(await showRss(@"name", getMediaQuery(@"name")))
+
+ get "/@name/search/rss":
+ cond '.' notin @"name"
+ respRss(await showRss(@"name", initQuery(params(request), name=(@"name"))))
diff --git a/src/routes/search.nim b/src/routes/search.nim
new file mode 100644
index 0000000..90f1d08
--- /dev/null
+++ b/src/routes/search.nim
@@ -0,0 +1,30 @@
+import strutils, sequtils, uri
+
+import jester
+
+import router_utils
+import ".."/[query, types, utils, api, agents, prefs]
+import ../views/[general, search]
+
+export search
+
+proc createSearchRouter*(cfg: Config) =
+ router search:
+ get "/search":
+ if @"text".len > 200:
+ resp Http400, showError("Search input too long.", cfg.title)
+
+ let prefs = cookiePrefs()
+ let query = initQuery(params(request))
+
+ case query.kind
+ of users:
+ if "," in @"text":
+ redirect("/" & @"text")
+ let users = await getSearch[Profile](query, @"after", getAgent())
+ resp renderMain(renderUserSearch(users, prefs), prefs, cfg.title, path=getPath())
+ of custom:
+ let tweets = await getSearch[Tweet](query, @"after", getAgent())
+ resp renderMain(renderTweetSearch(tweets, prefs, getPath()), prefs, cfg.title, path=getPath())
+ else:
+ resp Http404, showError("Invalid search.", cfg.title)
diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim
index 63edc01..41f3742 100644
--- a/src/routes/timeline.nim
+++ b/src/routes/timeline.nim
@@ -3,20 +3,20 @@ import asyncdispatch, strutils, sequtils, uri
import jester
import router_utils
-import ".."/[api, prefs, types, utils, cache, formatters, agents, search]
-import ../views/[general, profile, timeline, status]
+import ".."/[api, prefs, types, utils, cache, formatters, agents, query]
+import ../views/[general, profile, timeline, status, search]
include "../views/rss.nimf"
export uri, sequtils
export router_utils
-export api, cache, formatters, search, agents
+export api, cache, formatters, query, agents
export profile, timeline, status
type ProfileTimeline = (Profile, Timeline, seq[GalleryPhoto])
proc fetchSingleTimeline*(name, after, agent: string;
- query: Option[Query]): Future[ProfileTimeline] {.async.} =
+ query: Query): Future[ProfileTimeline] {.async.} =
let railFut = getPhotoRail(name, agent)
var timeline: Timeline
@@ -26,14 +26,14 @@ proc fetchSingleTimeline*(name, after, agent: string;
if cachedProfile.isSome:
profile = get(cachedProfile)
- if query.isNone:
+ if query.kind == posts:
if cachedProfile.isSome:
timeline = await getTimeline(name, after, agent)
else:
(profile, timeline) = await getProfileAndTimeline(name, agent, after)
cache(profile)
else:
- var timelineFut = getTimelineSearch(get(query), after, agent)
+ var timelineFut = getSearch[Tweet](query, after, agent)
if cachedProfile.isNone:
profile = await getCachedProfile(name, agent)
timeline = await timelineFut
@@ -42,16 +42,14 @@ proc fetchSingleTimeline*(name, after, agent: string;
return (profile, timeline, await railFut)
proc fetchMultiTimeline*(names: seq[string]; after, agent: string;
- query: Option[Query]): Future[Timeline] {.async.} =
+ query: Query): Future[Timeline] {.async.} =
var q = query
- if q.isSome:
- get(q).fromUser = names
- else:
- q = some(Query(kind: multi, fromUser: names, excludes: @["replies"]))
+ q.fromUser = names
+ if q.kind == posts and "replies" notin q.excludes:
+ q.excludes.add "replies"
+ return await getSearch[Tweet](q, after, agent)
- return await getTimelineSearch(get(q), after, agent)
-
-proc showTimeline*(name, after: string; query: Option[Query];
+proc showTimeline*(name, after: string; query: Query;
prefs: Prefs; path, title, rss: string): Future[string] {.async.} =
let agent = getAgent()
let names = name.strip(chars={'/'}).split(",").filterIt(it.len > 0)
@@ -64,7 +62,7 @@ proc showTimeline*(name, after: string; query: Option[Query];
else:
let
timeline = await fetchMultiTimeline(names, after, agent, query)
- html = renderMulti(timeline, names.join(","), prefs, path)
+ html = renderTweetSearch(timeline, prefs, path)
return renderMain(html, prefs, title, "Multi")
template respTimeline*(timeline: typed) =
@@ -79,27 +77,28 @@ proc createTimelineRouter*(cfg: Config) =
get "/@name/?":
cond '.' notin @"name"
let rss = "/$1/rss" % @"name"
- respTimeline(await showTimeline(@"name", @"after", none(Query), cookiePrefs(),
+ respTimeline(await showTimeline(@"name", @"after", Query(), cookiePrefs(),
getPath(), cfg.title, rss))
- get "/@name/search":
- cond '.' notin @"name"
- let query = initQuery(@"filter", @"include", @"not", @"sep", @"name")
- respTimeline(await showTimeline(@"name", @"after", some(query),
- cookiePrefs(), getPath(), cfg.title, ""))
-
get "/@name/replies":
cond '.' notin @"name"
let rss = "/$1/replies/rss" % @"name"
- respTimeline(await showTimeline(@"name", @"after", some(getReplyQuery(@"name")),
+ respTimeline(await showTimeline(@"name", @"after", getReplyQuery(@"name"),
cookiePrefs(), getPath(), cfg.title, rss))
get "/@name/media":
cond '.' notin @"name"
let rss = "/$1/media/rss" % @"name"
- respTimeline(await showTimeline(@"name", @"after", some(getMediaQuery(@"name")),
+ respTimeline(await showTimeline(@"name", @"after", getMediaQuery(@"name"),
cookiePrefs(), getPath(), cfg.title, rss))
+ get "/@name/search":
+ cond '.' notin @"name"
+ let query = initQuery(params(request), name=(@"name"))
+ let rss = "/$1/search/rss?$2" % [@"name", genQueryUrl(query, onlyParam=true)]
+ respTimeline(await showTimeline(@"name", @"after", query, cookiePrefs(),
+ getPath(), cfg.title, rss))
+
get "/@name/status/@id":
cond '.' notin @"name"
let prefs = cookiePrefs()
diff --git a/src/sass/general.scss b/src/sass/general.scss
index cd12b01..a92c6e3 100644
--- a/src/sass/general.scss
+++ b/src/sass/general.scss
@@ -10,7 +10,7 @@
@include center-panel($error_red);
}
-.search-panel > form {
+.search-bar > form {
@include center-panel($darkest-grey);
button {
diff --git a/src/sass/include/_mixins.css b/src/sass/include/_mixins.css
index 7042876..01e42b7 100644
--- a/src/sass/include/_mixins.css
+++ b/src/sass/include/_mixins.css
@@ -58,3 +58,40 @@
border-color: $accent_light;
}
}
+
+@mixin search-resize($width, $rows) {
+ @media(max-width: $width) {
+ .search-toggles {
+ grid-template-columns: repeat($rows, auto);
+ }
+
+ #search-panel-toggle:checked ~ .search-panel {
+ @if $rows == 6 {
+ 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;
+ }
+ }
+ }
+}
+
+@mixin create-toggle($elem, $height) {
+ ##{$elem}-toggle {
+ display: none;
+
+ &:checked ~ .#{$elem} {
+ max-height: $height;
+ }
+
+ &:checked ~ label .icon-down:before {
+ transform: rotate(180deg) translateY(-1px);
+ }
+ }
+}
diff --git a/src/sass/index.scss b/src/sass/index.scss
index 73b94b0..0d8c50c 100644
--- a/src/sass/index.scss
+++ b/src/sass/index.scss
@@ -6,6 +6,7 @@
@import 'navbar';
@import 'inputs';
@import 'timeline';
+@import 'search';
body {
background-color: $bg_color;
@@ -68,9 +69,6 @@ ul.about-list {
.container {
display: flex;
flex-wrap: wrap;
-}
-
-#content {
box-sizing: border-box;
padding-top: 50px;
margin: auto;
diff --git a/src/sass/inputs.scss b/src/sass/inputs.scss
index db9ad3e..0cea64b 100644
--- a/src/sass/inputs.scss
+++ b/src/sass/inputs.scss
@@ -12,7 +12,8 @@ button {
float: right;
}
-input[type="text"] {
+input[type="text"],
+input[type="date"] {
@include input-colors;
background-color: $bg_elements;
padding: 1px 4px;
@@ -22,9 +23,50 @@ input[type="text"] {
font-size: 14px;
}
+input[type="date"]::-webkit-inner-spin-button {
+ display: none;
+}
+
+input[type="date"]::-webkit-clear-button {
+ margin-left: 17px;
+ filter: grayscale(100%);
+ filter: hue-rotate(120deg);
+}
+
+input::-webkit-calendar-picker-indicator {
+ opacity: 0;
+}
+
+input::-webkit-datetime-edit-day-field:focus,
+input::-webkit-datetime-edit-month-field:focus,
+input::-webkit-datetime-edit-year-field:focus {
+ background-color: $accent;
+ color: $fg_color;
+ outline: none;
+}
+
+.date-range {
+ .date-input {
+ display: inline-block;
+ position: relative;
+ }
+
+ .icon-container {
+ pointer-events: none;
+ position: absolute;
+ top: 2px;
+ right: 5px;
+ }
+
+ .search-title {
+ margin: 0 2px;
+ }
+}
+
.icon-button button {
color: $accent;
text-decoration: none;
+ background: none;
border: none;
float: none;
padding: unset;
@@ -88,6 +130,10 @@ input[type="text"] {
}
}
+.pref-group {
+ display: inline;
+}
+
.preferences {
button {
margin: 6px 0 3px 0;
@@ -103,6 +149,10 @@ input[type="text"] {
max-width: 120px;
}
+ .pref-group {
+ display: block;
+ }
+
.pref-input {
position: relative;
margin-bottom: 6px;
diff --git a/src/sass/navbar.scss b/src/sass/navbar.scss
index 510bd81..45b2fa2 100644
--- a/src/sass/navbar.scss
+++ b/src/sass/navbar.scss
@@ -1,27 +1,25 @@
@import '_variables';
nav {
- z-index: 1000;
- background-color: $bg_overlays;
- box-shadow: 0 0 4px $shadow;
-}
-
-.nav-bar {
- padding: 0;
- width: 100%;
+ display: flex;
align-items: center;
position: fixed;
+ background-color: $bg_overlays;
+ box-shadow: 0 0 4px $shadow;
+ padding: 0;
+ width: 100%;
height: 50px;
+ z-index: 1000;
+}
- .inner-nav {
- margin: auto;
- box-sizing: border-box;
- padding: 0 10px;
- display: flex;
- align-items: center;
- flex-basis: 920px;
- height: 50px;
- }
+.inner-nav {
+ margin: auto;
+ box-sizing: border-box;
+ padding: 0 10px;
+ display: flex;
+ align-items: center;
+ flex-basis: 920px;
+ height: 50px;
}
.site-name {
@@ -39,7 +37,7 @@ nav {
height: 35px;
}
-.item {
+.nav-item {
display: flex;
flex: 1;
line-height: 50px;
diff --git a/src/sass/profile/_base.scss b/src/sass/profile/_base.scss
index 529d9c9..4e069af 100644
--- a/src/sass/profile/_base.scss
+++ b/src/sass/profile/_base.scss
@@ -4,13 +4,13 @@
@import 'card';
@import 'photo-rail';
-.profile-timeline, .profile-tabs {
- @include panel(auto, 900px);
-}
-
.profile-tabs {
- > .timeline-tab {
+ @include panel(auto, 900px);
+
+ .timeline-container {
+ float: right;
width: 68% !important;
+ max-width: unset;
}
}
@@ -43,11 +43,19 @@
top: 50px;
}
+.profile-result .username {
+ margin: 0 !important;
+}
+
+.profile-result .tweet-header {
+ margin-bottom: unset;
+}
+
@media(max-width: 600px) {
.profile-tabs {
width: 100vw;
- .timeline-tab {
+ .timeline-container {
width: 100% !important;
}
}
diff --git a/src/sass/profile/photo-rail.scss b/src/sass/profile/photo-rail.scss
index bae9e8e..503125c 100644
--- a/src/sass/profile/photo-rail.scss
+++ b/src/sass/profile/photo-rail.scss
@@ -16,13 +16,9 @@
&-header-mobile {
padding: 5px 12px 0;
display: none;
- }
-
- &-label {
- width: 100%;
+ width: calc(100% - 24px);
float: unset;
color: $accent;
- display: flex;
justify-content: space-between;
}
@@ -57,13 +53,9 @@
}
}
-#photo-rail-toggle {
- display: none;
-
- &:checked ~ .photo-rail-grid {
- max-height: 600px;
- padding-bottom: 12px;
- }
+@include create-toggle(photo-rail-grid, 640px);
+#photo-rail-grid-toggle:checked ~ .photo-rail-grid {
+ padding-bottom: 12px;
}
@media(max-width: 600px) {
@@ -72,7 +64,7 @@
}
.photo-rail-header-mobile {
- display: block;
+ display: flex;
}
.photo-rail-grid {
@@ -82,3 +74,23 @@
transition: max-height 0.4s;
}
}
+
+@media(max-width: 600px) {
+ .photo-rail-grid {
+ grid-template-columns: repeat(6, 1fr);
+ }
+
+ #photo-rail-grid-toggle:checked ~ .photo-rail-grid {
+ max-height: 300px;
+ }
+}
+
+@media(max-width: 450px) {
+ .photo-rail-grid {
+ grid-template-columns: repeat(4, 1fr);
+ }
+
+ #photo-rail-grid-toggle:checked ~ .photo-rail-grid {
+ max-height: 450px;
+ }
+}
diff --git a/src/sass/search.scss b/src/sass/search.scss
new file mode 100644
index 0000000..40e70f3
--- /dev/null
+++ b/src/sass/search.scss
@@ -0,0 +1,120 @@
+@import '_variables';
+@import '_mixins';
+
+.search-title {
+ font-weight: bold;
+ display: inline-block;
+ margin-top: 4px;
+}
+
+.search-field {
+ display: flex;
+ flex-wrap: wrap;
+
+ button {
+ margin: 0 2px 0 0;
+ height: 23px;
+ }
+
+ .pref-input {
+ margin: 0 4px 0 0;
+ flex-grow: 1;
+ height: 23px;
+ }
+
+ input[type="text"] {
+ height: calc(100% - 4px);
+ width: calc(100% - 8px);
+ }
+
+ > label {
+ display: inline;
+ background-color: #121212;
+ color: #F8F8F2;
+ border: 1px solid #FF6C6091;
+ padding: 1px 6px 2px 6px;
+ font-size: 14px;
+ cursor: pointer;
+ margin-bottom: 2px;
+
+ @include input-colors;
+ }
+
+ @include create-toggle(search-panel, 200px);
+}
+
+.search-panel {
+ width: 100%;
+ max-height: 0;
+ overflow: hidden;
+ transition: max-height 0.4s;
+
+ flex-grow: 1;
+ font-weight: initial;
+ text-align: left;
+
+ > div {
+ line-height: 1.7em;
+ }
+
+ .checkbox-container {
+ display: inline;
+ padding-right: unset;
+ margin-bottom: unset;
+ margin-left: 23px;
+ }
+
+ .checkbox {
+ right: unset;
+ left: -22px;
+ }
+
+ .checkbox-container .checkbox:after {
+ top: -4px;
+ }
+}
+
+.search-row {
+ display: flex;
+ flex-wrap: wrap;
+ line-height: unset;
+
+ > div {
+ flex-grow: 1;
+ flex-shrink: 1;
+ }
+
+ input {
+ height: 21px;
+ }
+
+ .pref-input {
+ display: block;
+ padding-bottom: 5px;
+
+ input {
+ height: 21px;
+ margin-top: 1px;
+ }
+ }
+}
+
+.search-toggles {
+ flex-grow: 1;
+ display: grid;
+ grid-template-columns: repeat(6, auto);
+ grid-column-gap: 10px;
+}
+
+.profile-tabs {
+ @include search-resize(820px, 5);
+ @include search-resize(725px, 4);
+ @include search-resize(600px, 6);
+ @include search-resize(560px, 5);
+ @include search-resize(480px, 4);
+ @include search-resize(410px, 3);
+}
+
+@include search-resize(560px, 5);
+@include search-resize(480px, 4);
+@include search-resize(410px, 3);
diff --git a/src/sass/timeline.scss b/src/sass/timeline.scss
index a9e3433..f193453 100644
--- a/src/sass/timeline.scss
+++ b/src/sass/timeline.scss
@@ -1,36 +1,28 @@
@import '_variables';
-#posts {
+.timeline-container {
+ @include panel(100%, 600px);
+}
+
+.timeline {
background-color: $bg_panel;
-}
-.timeline-tab {
- float: right;
- padding: 0;
- box-sizing: border-box;
- display: inline-block;
- font-size: 14px;
- text-align: left;
- vertical-align: top;
-}
-
-.multi-timeline {
- max-width: 600px;
- width: 100%;
- margin: 0 auto;
-
- .timeline-tab {
- width: 100%;
+ > div:not(:last-child) {
+ border-bottom: 1px solid $border_grey;
}
}
-.multi-header {
+.timeline-header {
background-color: $bg_panel;
text-align: center;
- padding: 10px;
+ padding: 8px;
display: block;
font-weight: bold;
margin-bottom: 5px;
+
+ button {
+ float: unset;
+ }
}
.tab {
@@ -72,20 +64,11 @@
}
}
-.timeline-tweet {
- border-bottom: 1px solid $border_grey;
-}
-
.timeline-footer {
background-color: $bg_panel;
padding: 6px 0;
}
-.timeline-header {
- background-color: $bg_panel;
- padding: 6px 0;
-}
-
.timeline-protected {
text-align: center;
@@ -119,11 +102,7 @@
background-color: $bg_panel;
text-align: center;
padding: .75em 0;
- display: block;
-
- &.status-el {
- border-bottom: 1px solid $border_grey;
- }
+ display: block !important;
a {
background-color: $darkest_grey;
@@ -137,3 +116,12 @@
}
}
}
+
+.timeline-item {
+ overflow-wrap: break-word;
+ border-left-width: 0;
+ min-width: 0;
+ padding: .75em;
+ display: flex;
+ position: relative;
+}
diff --git a/src/sass/tweet/_base.scss b/src/sass/tweet/_base.scss
index 94f4feb..a1320fd 100644
--- a/src/sass/tweet/_base.scss
+++ b/src/sass/tweet/_base.scss
@@ -7,23 +7,19 @@
@import 'poll';
@import 'quote';
-.status-el {
- overflow-wrap: break-word;
- border-left-width: 0;
- min-width: 0;
- padding: .75em;
- display: flex;
-
- .status-content {
- font-family: $font_3;
- line-height: 1.4em;
- }
-}
-
-.status-body {
+.tweet-body {
flex: 1;
min-width: 0;
margin-left: 58px;
+ pointer-events: none;
+ z-index: 1;
+}
+
+.tweet-content {
+ font-family: $font_3;
+ line-height: 1.4em;
+ pointer-events: all;
+ display: inline;
}
.tweet-header {
@@ -36,6 +32,7 @@
display: inline-block;
word-break: break-all;
max-width: 100%;
+ pointer-events: all;
}
}
@@ -79,7 +76,6 @@
float: left;
margin-top: 3px;
margin-left: -58px;
- position: absolute;
width: 48px;
height: 48px;
border-radius: 50%;
@@ -89,6 +85,7 @@
.replying-to {
color: $fg_dark;
margin: -2px 0 4px;
+ pointer-events: all;
}
.retweet, .pinned, .tweet-stats {
@@ -121,6 +118,7 @@
.show-thread {
display: block;
+ pointer-events: all;
}
.unavailable-box {
@@ -131,3 +129,15 @@
border-radius: 10px;
background-color: $bg_color;
}
+
+.tweet-link {
+ height: 100%;
+ width: 100%;
+ left: 0;
+ top: 0;
+ position: absolute;
+
+ &:hover {
+ background-color: #1a1a1a;
+ }
+}
diff --git a/src/sass/tweet/card.scss b/src/sass/tweet/card.scss
index e30cf4b..11039f7 100644
--- a/src/sass/tweet/card.scss
+++ b/src/sass/tweet/card.scss
@@ -3,6 +3,7 @@
.card {
margin: 5px 0;
+ pointer-events: all;
}
.card-container {
@@ -31,6 +32,7 @@
.card-title {
@include ellipsis;
+ white-space: unset;
font-weight: bold;
font-size: 1.15em;
}
diff --git a/src/sass/tweet/media.scss b/src/sass/tweet/media.scss
index a7ddf41..17de94a 100644
--- a/src/sass/tweet/media.scss
+++ b/src/sass/tweet/media.scss
@@ -9,9 +9,11 @@
flex-grow: 1;
max-height: 379.5px;
max-width: 533px;
+ pointer-events: all;
.still-image {
width: 100%;
+ display: block;
}
}
@@ -26,6 +28,7 @@
flex-flow: column;
background-color: $bg_color;
align-items: center;
+ pointer-events: all;
.image-attachment {
width: 100%;
@@ -66,7 +69,14 @@
.single-image {
display: inline-block;
- width: unset;
+ width: 100%;
+ max-height: 600px;
+
+ .attachments {
+ width: unset;
+ max-height: unset;
+ display: inherit;
+ }
}
.overlay-circle {
diff --git a/src/sass/tweet/poll.scss b/src/sass/tweet/poll.scss
index b58e92c..2709ba5 100644
--- a/src/sass/tweet/poll.scss
+++ b/src/sass/tweet/poll.scss
@@ -24,14 +24,17 @@
margin-right: 6px;
min-width: 30px;
text-align: right;
+ pointer-events: all;
}
.poll-choice-option {
position: relative;
+ pointer-events: all;
}
.poll-info {
color: $grey;
+ pointer-events: all;
}
.leader .poll-choice-bar {
diff --git a/src/sass/tweet/quote.scss b/src/sass/tweet/quote.scss
index 65b772e..7c435bc 100644
--- a/src/sass/tweet/quote.scss
+++ b/src/sass/tweet/quote.scss
@@ -8,6 +8,7 @@
overflow: auto;
padding: 6px;
position: relative;
+ pointer-events: all;
&:hover {
border-color: $grey;
@@ -30,6 +31,10 @@
position: absolute;
}
+.quote .quote-link {
+ z-index: 1;
+}
+
.quote-text {
overflow: hidden;
white-space: pre-wrap;
diff --git a/src/sass/tweet/thread.scss b/src/sass/tweet/thread.scss
index 1bbb6f4..e72597f 100644
--- a/src/sass/tweet/thread.scss
+++ b/src/sass/tweet/thread.scss
@@ -3,7 +3,6 @@
.conversation {
@include panel(100%, 600px);
- background-color: $bg_color !important;
}
.main-thread {
@@ -11,7 +10,7 @@
background-color: $bg_panel;
}
-.main-tweet .status-content {
+.main-tweet .tweet-content {
font-size: 20px;
}
@@ -21,7 +20,8 @@
}
.thread-line {
- .status-el::before {
+ .timeline-item::before,
+ &.timeline-item::before {
background: $accent_dark;
content: '';
position: relative;
@@ -32,6 +32,8 @@
margin-left: -3px;
margin-bottom: 37px;
top: 56px;
+ z-index: 1;
+ pointer-events: none;
}
.unavailable::before {
@@ -54,7 +56,7 @@
}
}
-.thread-last .status-el::before {
+.timeline-item.thread-last::before {
background: unset;
min-width: unset;
width: 0;
diff --git a/src/search.nim b/src/search.nim
deleted file mode 100644
index 7ad703e..0000000
--- a/src/search.nim
+++ /dev/null
@@ -1,87 +0,0 @@
-import strutils, strformat, sequtils
-
-import types
-
-const
- separators = @["AND", "OR"]
- validFilters = @[
- "media", "images", "twimg", "videos",
- "native_video", "consumer_video", "pro_video",
- "links", "news", "quote", "mentions",
- "replies", "retweets", "nativeretweets",
- "verified", "safe"
- ]
-
-# Experimental, this might break in the future
-# Till then, it results in shorter urls
-const
- posPrefix = "thGAVUV0VFVBa"
- posSuffix = "EjUAFQAlAFUAFQAA"
-
-proc initQuery*(filters, includes, excludes, separator: string; name=""): Query =
- var sep = separator.strip().toUpper()
- Query(
- kind: custom,
- filters: filters.split(",").filterIt(it in validFilters),
- includes: includes.split(",").filterIt(it in validFilters),
- excludes: excludes.split(",").filterIt(it in validFilters),
- fromUser: @[name],
- sep: if sep in separators: sep else: ""
- )
-
-proc getMediaQuery*(name: string): Query =
- Query(
- kind: media,
- filters: @["twimg", "native_video"],
- fromUser: @[name],
- sep: "OR"
- )
-
-proc getReplyQuery*(name: string): Query =
- Query(
- kind: replies,
- includes: @["nativeretweets"],
- fromUser: @[name]
- )
-
-proc genQueryParam*(query: Query): string =
- var filters: seq[string]
- var param: string
-
- for i, user in query.fromUser:
- param &= &"from:{user} "
- if i < query.fromUser.high:
- param &= "OR "
-
- for f in query.filters:
- filters.add "filter:" & f
- for i in query.includes:
- filters.add "include:" & i
- for e in query.excludes:
- filters.add "-filter:" & e
-
- return strip(param & filters.join(&" {query.sep} "))
-
-proc genQueryUrl*(query: Query): string =
- if query.kind == multi: return "?"
-
- result = &"/{query.kind}?"
- if query.kind != custom: return
-
- var params: seq[string]
- if query.filters.len > 0:
- params &= "filter=" & query.filters.join(",")
- if query.includes.len > 0:
- params &= "include=" & query.includes.join(",")
- if query.excludes.len > 0:
- params &= "not=" & query.excludes.join(",")
- if query.sep.len > 0:
- params &= "sep=" & query.sep
- if params.len > 0:
- result &= params.join("&") & "&"
-
-proc cleanPos*(pos: string): string =
- pos.multiReplace((posPrefix, ""), (posSuffix, ""))
-
-proc genPos*(pos: string): string =
- posPrefix & pos & posSuffix
diff --git a/src/types.nim b/src/types.nim
index cbdb213..be3ee20 100644
--- a/src/types.nim
+++ b/src/types.nim
@@ -57,14 +57,18 @@ dbFromTypes("cache.db", "", "", "", [Profile, Video])
type
QueryKind* = enum
- replies, media, multi, custom = "search"
+ posts, replies, media, users, custom
Query* = object
kind*: QueryKind
+ text*: string
filters*: seq[string]
includes*: seq[string]
excludes*: seq[string]
fromUser*: seq[string]
+ since*: string
+ until*: string
+ near*: string
sep*: string
Result*[T] = ref object
@@ -73,7 +77,7 @@ type
maxId*: string
hasMore*: bool
beginning*: bool
- query*: Option[Query]
+ query*: Query
Gif* = object
url*: string
diff --git a/src/utils.nim b/src/utils.nim
index c1499bf..a65e9cb 100644
--- a/src/utils.nim
+++ b/src/utils.nim
@@ -42,7 +42,7 @@ proc cleanFilename*(filename: string): string =
proc filterParams*(params: Table): seq[(string, string)] =
let filter = ["name", "id"]
- toSeq(params.pairs()).filterIt(it[0] notin filter)
+ toSeq(params.pairs()).filterIt(it[0] notin filter and it[1].len > 0)
proc isTwitterUrl*(url: string): bool =
parseUri(url).hostname in twitterDomains
diff --git a/src/views/general.nim b/src/views/general.nim
index 5df1269..058e64c 100644
--- a/src/views/general.nim
+++ b/src/views/general.nim
@@ -6,14 +6,15 @@ import ../utils, ../types
const doctype = "\n"
proc renderNavbar*(title, path, rss: string): VNode =
- buildHtml(nav(id="nav", class="nav-bar container")):
+ buildHtml(nav):
tdiv(class="inner-nav"):
- tdiv(class="item"):
+ tdiv(class="nav-item"):
a(class="site-name", href="/"): text title
a(href="/"): img(class="site-logo", src="/logo.png")
- tdiv(class="item right"):
+ tdiv(class="nav-item right"):
+ icon "search", title="Search", href="/search"
if rss.len > 0:
icon "rss", title="RSS Feed", href=rss
icon "info-circled", title="About", href="/about"
@@ -55,18 +56,11 @@ proc renderMain*(body: VNode; prefs: Prefs; title="Nitter"; titleText=""; desc="
body:
renderNavbar(title, path, rss)
- tdiv(id="content", class="container"):
+ tdiv(class="container"):
body
result = doctype & $node
-proc renderSearch*(): VNode =
- buildHtml(tdiv(class="panel-container")):
- tdiv(class="search-panel"):
- form(`method`="post", action="/search"):
- input(`type`="text", name="query", autofocus="", placeholder="Enter usernames...")
- button(`type`="submit"): icon "search"
-
proc renderError*(error: string): VNode =
buildHtml(tdiv(class="panel-container")):
tdiv(class="error-panel"):
diff --git a/src/views/preferences.nim b/src/views/preferences.nim
index 567a9d0..c503cbc 100644
--- a/src/views/preferences.nim
+++ b/src/views/preferences.nim
@@ -1,34 +1,9 @@
-import tables, macros, strformat, strutils, xmltree
+import tables, macros, strutils
import karax/[karaxdsl, vdom, vstyles]
import renderutils
import ../types, ../prefs_impl
-proc genCheckbox(pref, label: string; state: bool): VNode =
- buildHtml(tdiv(class="pref-group")):
- label(class="checkbox-container"):
- text label
- if state: input(name=pref, `type`="checkbox", checked="")
- else: input(name=pref, `type`="checkbox")
- span(class="checkbox")
-
-proc genSelect(pref, label, state: string; options: seq[string]): VNode =
- buildHtml(tdiv(class="pref-group")):
- label(`for`=pref): text label
- select(name=pref):
- for opt in options:
- if opt == state:
- option(value=opt, selected=""): text opt
- else:
- option(value=opt): text opt
-
-proc genInput(pref, label, state, placeholder: string): VNode =
- let s = xmltree.escape(state)
- let p = xmltree.escape(placeholder)
- buildHtml(tdiv(class="pref-group pref-input")):
- label(`for`=pref): text label
- verbatim &""
-
macro renderPrefs*(): untyped =
result = nnkCall.newTree(
ident("buildHtml"), ident("tdiv"), nnkStmtList.newTree())
diff --git a/src/views/profile.nim b/src/views/profile.nim
index 8b70b51..59e466c 100644
--- a/src/views/profile.nim
+++ b/src/views/profile.nim
@@ -1,7 +1,7 @@
import strutils, strformat
import karax/[karaxdsl, vdom, vstyles]
-import tweet, timeline, renderutils
+import renderutils, search
import ".."/[types, utils, formatters]
proc renderStat(num, class: string; text=""): VNode =
@@ -54,11 +54,10 @@ proc renderPhotoRail(profile: Profile; photoRail: seq[GalleryPhoto]): VNode =
a(href=(&"/{profile.username}/media")):
icon "picture", $profile.media & " Photos and videos"
- input(id="photo-rail-toggle", `type`="checkbox")
- tdiv(class="photo-rail-header-mobile"):
- label(`for`="photo-rail-toggle", class="photo-rail-label"):
- icon "picture", $profile.media & " Photos and videos"
- icon "down"
+ input(id="photo-rail-grid-toggle", `type`="checkbox")
+ label(`for`="photo-rail-grid-toggle", class="photo-rail-header-mobile"):
+ icon "picture", $profile.media & " Photos and videos"
+ icon "down"
tdiv(class="photo-rail-grid"):
for i, photo in photoRail:
@@ -75,8 +74,15 @@ proc renderBanner(profile: Profile): VNode =
a(href=getPicUrl(profile.banner), target="_blank"):
genImg(profile.banner)
+proc renderProtected(username: string): VNode =
+ buildHtml(tdiv(class="timeline-container")):
+ tdiv(class="timeline-header timeline-protected"):
+ h2: text "This account's tweets are protected."
+ p: text &"Only confirmed followers have access to @{username}'s tweets."
+
proc renderProfile*(profile: Profile; timeline: Timeline;
photoRail: seq[GalleryPhoto]; prefs: Prefs; path: string): VNode =
+ timeline.query.fromUser = @[profile.username]
buildHtml(tdiv(class="profile-tabs")):
if not prefs.hideBanner:
tdiv(class="profile-banner"):
@@ -88,11 +94,7 @@ proc renderProfile*(profile: Profile; timeline: Timeline;
if photoRail.len > 0:
renderPhotoRail(profile, photoRail)
- tdiv(class="timeline-tab"):
- renderTimeline(timeline, profile.username, profile.protected, prefs, path)
-
-proc renderMulti*(timeline: Timeline; usernames: string;
- prefs: Prefs; path: string): VNode =
- buildHtml(tdiv(class="multi-timeline")):
- tdiv(class="timeline-tab"):
- renderTimeline(timeline, usernames, false, prefs, path, multi=true)
+ if profile.protected:
+ renderProtected(profile.username)
+ else:
+ renderTweetSearch(timeline, prefs, path)
diff --git a/src/views/renderutils.nim b/src/views/renderutils.nim
index 23900ca..60445dd 100644
--- a/src/views/renderutils.nim
+++ b/src/views/renderutils.nim
@@ -1,4 +1,4 @@
-import strutils
+import strutils, strformat, xmltree
import karax/[karaxdsl, vdom]
import ../types, ../utils
@@ -39,9 +39,12 @@ proc linkText*(text: string; class=""): VNode =
buildHtml():
a(href=url, class=class): text text
-proc refererField*(path: string): VNode =
+proc hiddenField*(name, value: string): VNode =
buildHtml():
- verbatim "" % path
+ verbatim "" % [name, value]
+
+proc refererField*(path: string): VNode =
+ hiddenField("referer", path)
proc iconReferer*(icon, action, path: string, title=""): VNode =
buildHtml(form(`method`="get", action=action, class="icon-button")):
@@ -54,3 +57,37 @@ proc buttonReferer*(action, text, path: string; class=""; `method`="post"): VNod
refererField path
button(`type`="submit"):
text text
+
+proc genCheckbox*(pref, label: string; state: bool): VNode =
+ buildHtml(label(class="pref-group checkbox-container")):
+ text label
+ if state: input(name=pref, `type`="checkbox", checked="")
+ else: input(name=pref, `type`="checkbox")
+ span(class="checkbox")
+
+proc genInput*(pref, label, state, placeholder: string; class=""; autofocus=false): VNode =
+ let s = xmltree.escape(state)
+ let p = xmltree.escape(placeholder)
+ let a = if autofocus: "autofocus" else: ""
+ buildHtml(tdiv(class=("pref-group pref-input " & class))):
+ if label.len > 0:
+ label(`for`=pref): text label
+ verbatim &""
+
+proc genSelect*(pref, label, state: string; options: seq[string]): VNode =
+ buildHtml(tdiv(class="pref-group")):
+ label(`for`=pref): text label
+ select(name=pref):
+ for opt in options:
+ if opt == state:
+ option(value=opt, selected=""): text opt
+ else:
+ option(value=opt): text opt
+
+proc genDate*(pref, state: string): VNode =
+ buildHtml(span(class="date-input")):
+ if state.len > 0:
+ verbatim &""
+ else:
+ verbatim &""
+ icon "calendar"
diff --git a/src/views/search.nim b/src/views/search.nim
new file mode 100644
index 0000000..bfcc0b9
--- /dev/null
+++ b/src/views/search.nim
@@ -0,0 +1,123 @@
+import strutils, strformat, sequtils, unicode, tables
+import karax/[karaxdsl, vdom, vstyles]
+
+import renderutils, timeline
+import ".."/[types, formatters, query]
+
+let 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 =
+ buildHtml(tdiv(class="panel-container")):
+ tdiv(class="search-bar"):
+ form(`method`="get", action="/search"):
+ hiddenField("kind", "users")
+ input(`type`="text", name="text", autofocus="", placeholder="Enter username...")
+ button(`type`="submit"): icon "search"
+
+proc getTabClass(query: Query; tab: QueryKind): string =
+ var classes = @["tab-item"]
+ if query.kind == tab:
+ classes.add "active"
+ return classes.join(" ")
+
+proc renderProfileTabs*(query: Query; username: string): VNode =
+ let link = "/" & username
+ buildHtml(ul(class="tab")):
+ li(class=query.getTabClass(posts)):
+ a(href=link): text "Tweets"
+ li(class=query.getTabClass(replies)):
+ a(href=(link & "/replies")): text "Tweets & Replies"
+ li(class=query.getTabClass(media)):
+ a(href=(link & "/media")): text "Media"
+ li(class=query.getTabClass(custom)):
+ a(href=(link & "/search")): text "Search"
+
+proc renderSearchTabs*(query: Query): VNode =
+ var q = query
+ buildHtml(ul(class="tab")):
+ li(class=query.getTabClass(custom)):
+ q.kind = custom
+ a(href=genQueryUrl(q)): text "Tweets"
+ li(class=query.getTabClass(users)):
+ q.kind = 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")):
+ hiddenField("kind", "custom")
+ genInput("text", "", query.text, "Enter search...",
+ class="pref-inline", autofocus=true)
+ button(`type`="submit"): icon "search"
+ if isPanelOpen(query):
+ input(id="search-panel-toggle", `type`="checkbox", checked="")
+ else:
+ input(id="search-panel-toggle", `type`="checkbox")
+ 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, placeholder="Location...")
+
+proc renderTweetSearch*(tweets: Result[Tweet]; prefs: Prefs; path: string): VNode =
+ let query = tweets.query
+ buildHtml(tdiv(class="timeline-container")):
+ if query.fromUser.len > 1:
+ tdiv(class="timeline-header"):
+ text query.fromUser.join(" | ")
+ if query.fromUser.len == 0 or query.kind == custom:
+ tdiv(class="timeline-header"):
+ renderSearchPanel(query)
+
+ if query.fromUser.len > 0:
+ renderProfileTabs(query, query.fromUser.join(","))
+ else:
+ renderSearchTabs(query)
+
+ renderTimelineTweets(tweets, prefs, path)
+
+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")
+ genInput("text", "", users.query.text, "Enter username...", class="pref-inline")
+ button(`type`="submit"): icon "search"
+
+ renderSearchTabs(users.query)
+ renderTimelineUsers(users, prefs)
diff --git a/src/views/status.nim b/src/views/status.nim
index 0a4c918..ec6af21 100644
--- a/src/views/status.nim
+++ b/src/views/status.nim
@@ -6,7 +6,7 @@ import tweet
proc renderMoreReplies(thread: Thread): VNode =
let num = if thread.more != -1: $thread.more & " " else: ""
let reply = if thread.more == 1: "reply" else: "replies"
- buildHtml(tdiv(class="status-el more-replies")):
+ buildHtml(tdiv(class="timeline-item more-replies")):
a(class="more-replies-text", title="Not implemented yet"):
text $num & "more " & reply
@@ -21,7 +21,7 @@ proc renderReplyThread(thread: Thread; prefs: Prefs; path: string): VNode =
proc renderConversation*(conversation: Conversation; prefs: Prefs; path: string): VNode =
let hasAfter = conversation.after != nil
- buildHtml(tdiv(class="conversation", id="posts")):
+ buildHtml(tdiv(class="conversation")):
tdiv(class="main-thread"):
if conversation.before != nil:
tdiv(class="before-tweet thread-line"):
diff --git a/src/views/timeline.nim b/src/views/timeline.nim
index 7209b1a..9902f0d 100644
--- a/src/views/timeline.nim
+++ b/src/views/timeline.nim
@@ -1,99 +1,99 @@
import strutils, strformat, sequtils, algorithm, times
import karax/[karaxdsl, vdom, vstyles]
-import ../types, ../search
+import ".."/[types, query, formatters]
import tweet, renderutils
-proc getQuery(timeline: Timeline): string =
- if timeline.query.isNone: "?"
- else: genQueryUrl(get(timeline.query))
+proc getQuery(query: Query): string =
+ if query.kind == posts:
+ result = "?"
+ else:
+ result = genQueryUrl(query)
+ if result[^1] != '?':
+ result &= "&"
-proc getTabClass(timeline: Timeline; tab: string): string =
- var classes = @["tab-item"]
+proc renderNewer(query: Query): VNode =
+ buildHtml(tdiv(class="timeline-item show-more")):
+ a(href=(getQuery(query).strip(chars={'?', '&'}))):
+ text "Load newest"
- if timeline.query.isNone or get(timeline.query).kind == multi:
- if tab == "posts":
- classes.add "active"
- elif $get(timeline.query).kind == tab:
- classes.add "active"
-
- return classes.join(" ")
-
-proc renderSearchTabs(timeline: Timeline; username: string): VNode =
- let link = "/" & username
- buildHtml(ul(class="tab")):
- li(class=timeline.getTabClass("posts")):
- a(href=link): text "Tweets"
- li(class=timeline.getTabClass("replies")):
- a(href=(link & "/replies")): text "Tweets & Replies"
- li(class=timeline.getTabClass("media")):
- a(href=(link & "/media")): text "Media"
-
-proc renderNewer(timeline: Timeline; username: string): VNode =
- buildHtml(tdiv(class="status-el show-more")):
- a(href=("/" & username & getQuery(timeline).strip(chars={'?'}))):
- text "Load newest tweets"
-
-proc renderOlder(timeline: Timeline; username: string): VNode =
+proc renderOlder(query: Query; minId: string): VNode =
buildHtml(tdiv(class="show-more")):
- a(href=(&"/{username}{getQuery(timeline)}after={timeline.minId}")):
- text "Load older tweets"
+ a(href=(&"{getQuery(query)}after={minId}")):
+ text "Load older"
proc renderNoMore(): VNode =
buildHtml(tdiv(class="timeline-footer")):
h2(class="timeline-end"):
- text "No more tweets."
+ text "No more items"
proc renderNoneFound(): VNode =
buildHtml(tdiv(class="timeline-header")):
h2(class="timeline-none"):
- text "No tweets found."
-
-proc renderProtected(username: string): VNode =
- buildHtml(tdiv(class="timeline-header timeline-protected")):
- h2: text "This account's tweets are protected."
- p: text &"Only confirmed followers have access to @{username}'s tweets."
+ text "No items found"
proc renderThread(thread: seq[Tweet]; prefs: Prefs; path: string): VNode =
- buildHtml(tdiv(class="timeline-tweet thread-line")):
+ buildHtml(tdiv(class="thread-line")):
for i, threadTweet in thread.sortedByIt(it.time):
+ let show = i == thread.len and thread[0].id != threadTweet.threadId
renderTweet(threadTweet, prefs, path, class="thread",
- index=i, total=thread.high)
+ index=i, total=thread.high, showThread=show)
proc threadFilter(it: Tweet; tweetThread: string): bool =
it.retweet.isNone and it.reply.len == 0 and it.threadId == tweetThread
-proc renderTweets(timeline: Timeline; prefs: Prefs; path: string): VNode =
- buildHtml(tdiv(id="posts")):
- var threads: seq[string]
- for tweet in timeline.content:
- if tweet.threadId in threads: continue
- let thread = timeline.content.filterIt(threadFilter(it, tweet.threadId))
- if thread.len < 2:
- renderTweet(tweet, prefs, path, class="timeline-tweet")
- else:
- renderThread(thread, prefs, path)
- threads &= tweet.threadId
+proc renderUser(user: Profile; prefs: Prefs): VNode =
+ buildHtml(tdiv(class="timeline-item")):
+ a(class="tweet-link", href=("/" & user.username))
+ tdiv(class="tweet-body profile-result"):
+ tdiv(class="tweet-header"):
+ a(class="tweet-avatar", href=("/" & user.username)):
+ genImg(user.getUserpic("_bigger"), class="avatar")
-proc renderTimeline*(timeline: Timeline; username: string; protected: bool;
- prefs: Prefs; path: string; multi=false): VNode =
- buildHtml(tdiv):
- if multi:
- tdiv(class="multi-header"):
- text username.replace(",", " | ")
+ tdiv(class="tweet-name-row"):
+ tdiv(class="fullname-and-username"):
+ linkUser(user, class="fullname")
+ linkUser(user, class="username")
- if not protected:
- renderSearchTabs(timeline, username)
- if not timeline.beginning:
- renderNewer(timeline, username)
+ tdiv(class="tweet-content media-body"):
+ verbatim linkifyText(user.bio, prefs)
- if protected:
- renderProtected(username)
- elif timeline.content.len == 0:
+proc renderTimelineUsers*(results: Result[Profile]; prefs: Prefs): VNode =
+ buildHtml(tdiv(class="timeline")):
+ if not results.beginning:
+ renderNewer(results.query)
+
+ if results.content.len > 0:
+ for user in results.content:
+ renderUser(user, prefs)
+ renderOlder(results.query, results.minId)
+ elif results.beginning:
renderNoneFound()
else:
- renderTweets(timeline, prefs, path)
- if timeline.hasMore or timeline.query.isSome:
- renderOlder(timeline, username)
+ renderNoMore()
+
+proc renderTimelineTweets*(results: Result[Tweet]; prefs: Prefs; path: string): VNode =
+ buildHtml(tdiv(class="timeline")):
+ if not results.beginning:
+ renderNewer(results.query)
+
+ if results.content.len == 0:
+ renderNoneFound()
+ else:
+ var threads: seq[string]
+ var retweets: seq[string]
+ for tweet in results.content:
+ if tweet.threadId in threads or tweet.id in retweets: continue
+ let thread = results.content.filterIt(threadFilter(it, tweet.threadId))
+ if thread.len < 2:
+ if tweet.retweet.isSome:
+ retweets &= tweet.id
+ renderTweet(tweet, prefs, path, showThread=tweet.hasThread)
+ else:
+ renderThread(thread, prefs, path)
+ threads &= tweet.threadId
+
+ if results.hasMore or results.query.kind != posts:
+ renderOlder(results.query, results.minId)
else:
renderNoMore()
diff --git a/src/views/tweet.nim b/src/views/tweet.nim
index d56e5de..ff30e7b 100644
--- a/src/views/tweet.nim
+++ b/src/views/tweet.nim
@@ -1,4 +1,4 @@
-import strutils, sequtils
+import strutils, sequtils, strformat
import karax/[karaxdsl, vdom, vstyles]
import renderutils
@@ -31,19 +31,24 @@ proc renderAlbum(tweet: Tweet): VNode =
let
groups = if tweet.photos.len < 3: @[tweet.photos]
else: tweet.photos.distribute(2)
- class = if groups.len == 1 and groups[0].len == 1: "single-image"
- else: ""
- buildHtml(tdiv(class=("attachments " & class))):
- for i, photos in groups:
- let margin = if i > 0: ".25em" else: ""
- let flex = if photos.len > 1 or groups.len > 1: "flex" else: "block"
- tdiv(class="gallery-row", style={marginTop: margin}):
- for photo in photos:
- tdiv(class="attachment image"):
- a(href=getPicUrl(photo & "?name=orig"), class="still-image",
- target="_blank", style={display: flex}):
- genImg(photo)
+ if groups.len == 1 and groups[0].len == 1:
+ buildHtml(tdiv(class="single-image")):
+ tdiv(class="attachments gallery-row"):
+ a(href=getPicUrl(groups[0][0] & "?name=orig"), class="still-image",
+ target="_blank"):
+ genImg(groups[0][0])
+ else:
+ buildHtml(tdiv(class="attachments")):
+ for i, photos in groups:
+ let margin = if i > 0: ".25em" else: ""
+ let flex = if photos.len > 1 or groups.len > 1: "flex" else: "block"
+ tdiv(class="gallery-row", style={marginTop: margin}):
+ for photo in photos:
+ tdiv(class="attachment image"):
+ a(href=getPicUrl(photo & "?name=orig"), class="still-image",
+ target="_blank", style={display: flex}):
+ genImg(photo)
proc isPlaybackEnabled(prefs: Prefs; video: Video): bool =
case video.playbackType
@@ -217,50 +222,49 @@ proc renderQuote(quote: Quote; prefs: Prefs): VNode =
text "Show this thread"
proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class="";
- index=0; total=(-1); last=false): VNode =
+ index=0; total=(-1); last=false; showThread=false): VNode =
var divClass = class
if index == total or last:
divClass = "thread-last " & class
if not tweet.available:
- return buildHtml(tdiv(class=divClass)):
- tdiv(class="status-el unavailable"):
- tdiv(class="unavailable-box"):
- if tweet.tombstone.len > 0:
- text tweet.tombstone
- else:
- text "This tweet is unavailable"
+ return buildHtml(tdiv(class=divClass & "unavailable timeline-item")):
+ tdiv(class="unavailable-box"):
+ if tweet.tombstone.len > 0:
+ text tweet.tombstone
+ else:
+ text "This tweet is unavailable"
- buildHtml(tdiv(class=divClass)):
- tdiv(class="status-el"):
- tdiv(class="status-body"):
- var views = ""
- renderHeader(tweet)
+ buildHtml(tdiv(class=("timeline-item " & divClass))):
+ a(class="tweet-link", href=getLink(tweet))
+ tdiv(class="tweet-body"):
+ var views = ""
+ renderHeader(tweet)
- if index == 0 and tweet.reply.len > 0:
- renderReply(tweet)
+ if index == 0 and tweet.reply.len > 0:
+ renderReply(tweet)
- tdiv(class="status-content media-body"):
- verbatim linkifyText(tweet.text, prefs)
+ tdiv(class="tweet-content media-body"):
+ verbatim linkifyText(tweet.text, prefs)
- if tweet.quote.isSome:
- renderQuote(tweet.quote.get(), prefs)
+ if tweet.quote.isSome:
+ renderQuote(tweet.quote.get(), prefs)
- if tweet.card.isSome:
- renderCard(tweet.card.get(), prefs, path)
- elif tweet.photos.len > 0:
- renderAlbum(tweet)
- elif tweet.video.isSome:
- renderVideo(tweet.video.get(), prefs, path)
- views = tweet.video.get().views
- elif tweet.gif.isSome:
- renderGif(tweet.gif.get(), prefs)
- elif tweet.poll.isSome:
- renderPoll(tweet.poll.get())
+ if tweet.card.isSome:
+ renderCard(tweet.card.get(), prefs, path)
+ elif tweet.photos.len > 0:
+ renderAlbum(tweet)
+ elif tweet.video.isSome:
+ renderVideo(tweet.video.get(), prefs, path)
+ views = tweet.video.get().views
+ elif tweet.gif.isSome:
+ renderGif(tweet.gif.get(), prefs)
+ elif tweet.poll.isSome:
+ renderPoll(tweet.poll.get())
- if not prefs.hideTweetStats:
- renderStats(tweet.stats, views)
+ if not prefs.hideTweetStats:
+ renderStats(tweet.stats, views)
- if tweet.hasThread and "timeline" in class:
- a(class="show-thread", href=getLink(tweet)):
- text "Show this thread"
+ if showThread:
+ a(class="show-thread", href=getLink(tweet)):
+ text "Show this thread"
diff --git a/tests/base.py b/tests/base.py
index 1e1ed8f..0aa7604 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -31,7 +31,7 @@ class Tweet(object):
self.fullname = namerow + '.fullname'
self.username = namerow + '.username'
self.date = namerow + '.tweet-date'
- self.text = tweet + '.status-content.media-body'
+ self.text = tweet + '.tweet-content.media-body'
self.retweet = tweet + '.retweet'
self.reply = tweet + '.replying-to'
@@ -50,7 +50,7 @@ class Profile(object):
class Timeline(object):
- newest = 'div[class="status-el show-more"]'
+ newest = 'div[class="timeline-item show-more"]'
older = 'div[class="show-more"]'
end = '.timeline-end'
none = '.timeline-none'
@@ -63,8 +63,8 @@ class Conversation(object):
after = '.after-tweet'
replies = '.replies'
thread = '.reply'
- tweet = '.status-el'
- tweet_text = '.status-content'
+ tweet = '.timeline-item'
+ tweet_text = '.tweet-content'
class Poll(object):
@@ -95,9 +95,9 @@ class BaseTestCase(BaseCase):
def search_username(self, username):
self.open_nitter()
- self.update_text('.search-panel input', username)
- self.submit('.search-panel form')
+ self.update_text('.search-bar input[type=text]', username)
+ self.submit('.search-bar form')
def get_timeline_tweet(num=1):
- return Tweet(f'#posts > div:nth-child({num}) ')
+ return Tweet(f'.timeline > div:nth-child({num}) ')
diff --git a/tests/test_timeline.py b/tests/test_timeline.py
index 884350e..8829735 100644
--- a/tests/test_timeline.py
+++ b/tests/test_timeline.py
@@ -37,21 +37,21 @@ class TweetTest(BaseTestCase):
@parameterized.expand(short)
def test_short(self, username):
self.open_nitter(username)
- self.assert_text('No more tweets.', Timeline.end)
+ self.assert_text('No more items', Timeline.end)
self.assert_element_absent(Timeline.newest)
self.assert_element_absent(Timeline.older)
@parameterized.expand(no_more)
def test_no_more(self, username):
self.open_nitter(username)
- self.assert_text('No more tweets.', Timeline.end)
+ self.assert_text('No more items', Timeline.end)
self.assert_element_present(Timeline.newest)
self.assert_element_absent(Timeline.older)
@parameterized.expand(none_found)
def test_none_found(self, username):
self.open_nitter(username)
- self.assert_text('No tweets found.', Timeline.none)
+ self.assert_text('No items found', Timeline.none)
self.assert_element_present(Timeline.newest)
self.assert_element_absent(Timeline.older)
self.assert_element_absent(Timeline.end)
@@ -59,7 +59,7 @@ class TweetTest(BaseTestCase):
@parameterized.expand(empty)
def test_empty(self, username):
self.open_nitter(username)
- self.assert_text('No tweets found.', Timeline.none)
+ self.assert_text('No items found', Timeline.none)
self.assert_element_absent(Timeline.newest)
self.assert_element_absent(Timeline.older)
self.assert_element_absent(Timeline.end)
diff --git a/tests/test_tweet.py b/tests/test_tweet.py
index 8520603..9c86a74 100644
--- a/tests/test_tweet.py
+++ b/tests/test_tweet.py
@@ -147,6 +147,6 @@ class TweetTest(BaseTestCase):
@parameterized.expand(reply)
def test_reply(self, tweet, username, reply):
self.open_nitter(tweet)
- tweet = get_timeline_tweet(1)
+ tweet = get_timeline_tweet(2)
self.assert_text(username, tweet.username)
self.assert_text('Replying to ' + reply, tweet.reply)