commit
						6b437d5f87
					
				| 
						 | 
				
			
			@ -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
								
								
								
								
							
							
						
						
									
										
											BIN
										
									
								
								screenshot.png
								
								
								
								
							
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 910 KiB After Width: | Height: | Size: 912 KiB  | 
| 
						 | 
				
			
			@ -1,2 +1,2 @@
 | 
			
		|||
import api/[media, profile, timeline, tweet, search]
 | 
			
		||||
import api/[profile, timeline, tweet, search, media]
 | 
			
		||||
export profile, timeline, tweet, search, media
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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({
 | 
			
		||||
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=tweets&vertical=default&q=$1&src=typd" % queryEncoded)),
 | 
			
		||||
      "Referer": $(base / ("search?f=$1&q=$2&src=typd" % [kind, encoded])),
 | 
			
		||||
      "User-Agent": agent,
 | 
			
		||||
      "X-Requested-With": "XMLHttpRequest",
 | 
			
		||||
      "Authority": "twitter.com",
 | 
			
		||||
      "Accept-Language": lang
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
  let params = {
 | 
			
		||||
    "f": "tweets",
 | 
			
		||||
    params = {
 | 
			
		||||
      "f": kind,
 | 
			
		||||
      "vertical": "default",
 | 
			
		||||
    "q": queryParam,
 | 
			
		||||
      "q": param,
 | 
			
		||||
      "src": "typd",
 | 
			
		||||
      "include_available_features": "1",
 | 
			
		||||
      "include_entities": "1",
 | 
			
		||||
    "max_position": if after.len > 0: genPos(after) else: "0",
 | 
			
		||||
      "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")
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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({
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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:
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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+([;.,!\)'%]|')", "</a>$1")
 | 
			
		||||
  result = result.replace(re"^\. <a", ".<a")
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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, ""
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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()
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			@ -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"))))
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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)
 | 
			
		||||
| 
						 | 
				
			
			@ -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()
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -10,7 +10,7 @@
 | 
			
		|||
    @include center-panel($error_red);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.search-panel > form {
 | 
			
		||||
.search-bar > form {
 | 
			
		||||
    @include center-panel($darkest-grey);
 | 
			
		||||
 | 
			
		||||
    button {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,19 +1,18 @@
 | 
			
		|||
@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 {
 | 
			
		||||
.inner-nav {
 | 
			
		||||
    margin: auto;
 | 
			
		||||
    box-sizing: border-box;
 | 
			
		||||
    padding: 0 10px;
 | 
			
		||||
| 
						 | 
				
			
			@ -21,7 +20,6 @@ nav {
 | 
			
		|||
    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;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
@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;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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: 100%;
 | 
			
		||||
    max-height: 600px;
 | 
			
		||||
 | 
			
		||||
    .attachments {
 | 
			
		||||
        width: unset;
 | 
			
		||||
        max-height: unset;
 | 
			
		||||
        display: inherit;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.overlay-circle {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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,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())
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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,9 +54,8 @@ 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"):
 | 
			
		||||
    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"
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -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)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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)
 | 
			
		||||
| 
						 | 
				
			
			@ -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"):
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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")):
 | 
			
		||||
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:
 | 
			
		||||
      var threads: seq[string]
 | 
			
		||||
    for tweet in timeline.content:
 | 
			
		||||
      if tweet.threadId in threads: continue
 | 
			
		||||
      let thread = timeline.content.filterIt(threadFilter(it, tweet.threadId))
 | 
			
		||||
      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:
 | 
			
		||||
        renderTweet(tweet, prefs, path, class="timeline-tweet")
 | 
			
		||||
          if tweet.retweet.isSome:
 | 
			
		||||
            retweets &= tweet.id
 | 
			
		||||
          renderTweet(tweet, prefs, path, showThread=tweet.hasThread)
 | 
			
		||||
        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:
 | 
			
		||||
      renderNoneFound()
 | 
			
		||||
    else:
 | 
			
		||||
      renderTweets(timeline, prefs, path)
 | 
			
		||||
      if timeline.hasMore or timeline.query.isSome:
 | 
			
		||||
        renderOlder(timeline, username)
 | 
			
		||||
      if results.hasMore or results.query.kind != posts:
 | 
			
		||||
        renderOlder(results.query, results.minId)
 | 
			
		||||
      else:
 | 
			
		||||
        renderNoMore()
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,4 @@
 | 
			
		|||
import strutils, sequtils
 | 
			
		||||
import strutils, sequtils, strformat
 | 
			
		||||
import karax/[karaxdsl, vdom, vstyles]
 | 
			
		||||
 | 
			
		||||
import renderutils
 | 
			
		||||
| 
						 | 
				
			
			@ -31,10 +31,15 @@ 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))):
 | 
			
		||||
  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"
 | 
			
		||||
| 
						 | 
				
			
			@ -217,30 +222,29 @@ 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"):
 | 
			
		||||
    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"):
 | 
			
		||||
  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="status-content media-body"):
 | 
			
		||||
      tdiv(class="tweet-content media-body"):
 | 
			
		||||
        verbatim linkifyText(tweet.text, prefs)
 | 
			
		||||
 | 
			
		||||
      if tweet.quote.isSome:
 | 
			
		||||
| 
						 | 
				
			
			@ -261,6 +265,6 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class="";
 | 
			
		|||
      if not prefs.hideTweetStats:
 | 
			
		||||
        renderStats(tweet.stats, views)
 | 
			
		||||
 | 
			
		||||
        if tweet.hasThread and "timeline" in class:
 | 
			
		||||
      if showThread:
 | 
			
		||||
        a(class="show-thread", href=getLink(tweet)):
 | 
			
		||||
          text "Show this thread"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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}) ')
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue