Просмотр исходного кода

Merge pull request #39 from zedeus/search

Search
Zed 7 лет назад
Родитель
Сommit
6b437d5f87

+ 0 - 2
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

BIN
screenshot.png


+ 1 - 1
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

+ 5 - 5
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

+ 53 - 29
src/api/search.nim

@@ -1,32 +1,56 @@
 import httpclient, asyncdispatch, htmlparser
-import sequtils, strutils, json, xmltree, uri
-
-import ".."/[types, parser, parserutils, formatters, search]
-import utils, consts, media, timeline
-
-proc getTimelineSearch*(query: Query; after, agent: string): Future[Timeline] {.async.} =
-  let queryParam = genQueryParam(query)
-  let queryEncoded = encodeUrl(queryParam, usePlus=false)
-
-  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
-  })
-
-  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"
-  }
+import strutils, json, xmltree, uri
+
+import ".."/[types, parser, parserutils, query]
+import utils, consts, timeline
+
+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
+  )
+
+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
+
+    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")

+ 4 - 4
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({

+ 2 - 2
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:

+ 14 - 3
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%])<a", "$1 <a")
   result = result.replace(re"</a>\s+([;.,!\)'%]|&apos;)", "</a>$1")
   result = result.replace(re"^\. <a", ".<a")

+ 3 - 6
src/nitter.nim

@@ -5,13 +5,14 @@ import jester
 
 import types, config, prefs
 import views/[general, about]
-import routes/[preferences, timeline, media, rss]
+import routes/[preferences, timeline, media, search, rss]
 
 const configPath {.strdefine.} = "./nitter.conf"
 let cfg = getConfig(configPath)
 
 createPrefRouter(cfg)
 createTimelineRouter(cfg)
+createSearchRouter(cfg)
 createMediaRouter(cfg)
 createRssRouter(cfg)
 
@@ -27,13 +28,9 @@ routes:
   get "/about":
     resp renderMain(renderAbout(), Prefs(), cfg.title)
 
-  post "/search":
-    if @"query".len == 0:
-      resp Http404, showError("Please enter a username.", cfg.title)
-    redirect("/" & @"query")
-
   extend preferences, ""
   extend rss, ""
+  extend search, ""
   extend timeline, ""
   extend media, ""
 

+ 9 - 9
src/parser.nim

@@ -23,14 +23,14 @@ proc parseTimelineProfile*(node: XmlNode): Profile =
 
   result.getProfileStats(node.select(".ProfileNav-list"))
 
-proc parsePopupProfile*(node: XmlNode): Profile =
-  let profile = node.select(".profile-card")
+proc parsePopupProfile*(node: XmlNode; selector=".profile-card"): Profile =
+  let profile = node.select(selector)
   if profile == nil: return
 
   result = Profile(
     fullname:  profile.getName(".fullname"),
     username:  profile.getUsername(".username"),
-    bio:       profile.getBio(".bio"),
+    bio:       profile.getBio(".bio", fallback=".ProfileCard-bio"),
     userpic:   profile.getAvatar(".ProfileCard-avatarImage"),
     verified:  isVerified(profile),
     protected: isProtected(profile),
@@ -104,20 +104,20 @@ proc parseTweet*(node: XmlNode): Tweet =
 
   let by = tweet.selectText(".js-retweet-text > 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

+ 9 - 6
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()

+ 127 - 0
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

+ 9 - 5
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"))))

+ 30 - 0
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)

+ 23 - 24
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"]))
-
-  return await getTimelineSearch(get(q), after, agent)
+  q.fromUser = names
+  if q.kind == posts and "replies" notin q.excludes:
+    q.excludes.add "replies"
+  return await getSearch[Tweet](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()

+ 1 - 1
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 {

+ 37 - 0
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);
+        }
+    }
+}

+ 1 - 3
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;

+ 51 - 1
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;

+ 14 - 16
src/sass/navbar.scss

@@ -1,27 +1,25 @@
 @import '_variables';
 
 nav {
-    z-index: 1000;
+    display: flex;
+    align-items: center;
+    position: fixed;
     background-color: $bg_overlays;
     box-shadow: 0 0 4px $shadow;
-}
-
-.nav-bar {
     padding: 0;
     width: 100%;
-    align-items: center;
-    position: fixed;
     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;

+ 13 - 5
src/sass/profile/_base.scss

@@ -4,13 +4,13 @@
 @import 'card';
 @import 'photo-rail';
 
-.profile-timeline, .profile-tabs {
+.profile-tabs {
     @include panel(auto, 900px);
-}
 
-.profile-tabs {
-    > .timeline-tab {
+    .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;
         }
     }

+ 25 - 13
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;
+    }
+}

+ 120 - 0
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);

+ 22 - 34
src/sass/timeline.scss

@@ -1,36 +1,28 @@
 @import '_variables';
 
-#posts {
-    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;
+.timeline-container {
+    @include panel(100%, 600px);
 }
 
-.multi-timeline {
-    max-width: 600px;
-    width: 100%;
-    margin: 0 auto;
+.timeline {
+    background-color: $bg_panel;
 
-    .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;
+}

+ 25 - 15
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;
+    }
+}

+ 2 - 0
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;
 }

+ 11 - 1
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 {

+ 3 - 0
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 {

+ 5 - 0
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;

+ 6 - 4
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;

+ 0 - 87
src/search.nim

@@ -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

+ 6 - 2
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

+ 1 - 1
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

+ 5 - 11
src/views/general.nim

@@ -6,14 +6,15 @@ import ../utils, ../types
 const doctype = "<!DOCTYPE html>\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"):

+ 1 - 26
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 &"<input name={pref} type=\"text\" placeholder=\"{p}\" value=\"{s}\"/>"
-
 macro renderPrefs*(): untyped =
   result = nnkCall.newTree(
     ident("buildHtml"), ident("tdiv"), nnkStmtList.newTree())

+ 16 - 14
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)

+ 40 - 3
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 "<input name=\"referer\" style=\"display: none\" value=\"$1\"/>" % path
+    verbatim "<input name=\"$1\" style=\"display: none\" value=\"$2\"/>" % [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 &"<input name={pref} type=\"text\" placeholder=\"{p}\" value=\"{s}\" {a}/>"
+
+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 &"<input name={pref} type=\"date\" value=\"{state}\"/>"
+    else:
+      verbatim &"<input name={pref} type=\"date\"/>"
+    icon "calendar"

+ 123 - 0
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)

+ 2 - 2
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"):

+ 73 - 73
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 getTabClass(timeline: Timeline; tab: string): string =
-  var classes = @["tab-item"]
-
-  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 getQuery(query: Query): string =
+  if query.kind == posts:
+    result = "?"
+  else:
+    result = genQueryUrl(query)
+    if result[^1] != '?':
+      result &= "&"
+
+proc renderNewer(query: Query): VNode =
+  buildHtml(tdiv(class="timeline-item show-more")):
+    a(href=(getQuery(query).strip(chars={'?', '&'}))):
+      text "Load newest"
+
+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 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(",", " | ")
-
-    if not protected:
-      renderSearchTabs(timeline, username)
-      if not timeline.beginning:
-        renderNewer(timeline, username)
-
-    if protected:
-      renderProtected(username)
-    elif timeline.content.len == 0:
+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")
+
+        tdiv(class="tweet-name-row"):
+          tdiv(class="fullname-and-username"):
+            linkUser(user, class="fullname")
+        linkUser(user, class="username")
+
+      tdiv(class="tweet-content media-body"):
+        verbatim linkifyText(user.bio, prefs)
+
+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:
+      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:
-      renderTweets(timeline, prefs, path)
-      if timeline.hasMore or timeline.query.isSome:
-        renderOlder(timeline, username)
+      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()

+ 60 - 56
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"
-
-  buildHtml(tdiv(class=divClass)):
-    tdiv(class="status-el"):
-      tdiv(class="status-body"):
-        var views = ""
-        renderHeader(tweet)
-
-        if index == 0 and tweet.reply.len > 0:
-          renderReply(tweet)
-
-        tdiv(class="status-content media-body"):
-          verbatim linkifyText(tweet.text, 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 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"
+    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=("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)
+
+      tdiv(class="tweet-content media-body"):
+        verbatim linkifyText(tweet.text, 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 not prefs.hideTweetStats:
+        renderStats(tweet.stats, views)
+
+      if showThread:
+        a(class="show-thread", href=getLink(tweet)):
+          text "Show this thread"

+ 7 - 7
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}) ')

+ 4 - 4
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)

+ 1 - 1
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)