commit
6b437d5f87
44 changed files with 951 additions and 463 deletions
|
@ -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({
|
||||
"Accept": jsonAccept,
|
||||
"Referer": $(base / ("search?f=tweets&vertical=default&q=$1&src=typd" % queryEncoded)),
|
||||
"User-Agent": agent,
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"Authority": "twitter.com",
|
||||
"Accept-Language": lang
|
||||
})
|
||||
proc getSearch*[T](query: Query; after, agent: string): Future[Result[T]] {.async.} =
|
||||
let
|
||||
kind = if query.kind == users: "users" else: "tweets"
|
||||
pos = when T is Tweet: genPos(after) else: after
|
||||
|
||||
let params = {
|
||||
"f": "tweets",
|
||||
"vertical": "default",
|
||||
"q": queryParam,
|
||||
"src": "typd",
|
||||
"include_available_features": "1",
|
||||
"include_entities": "1",
|
||||
"max_position": if after.len > 0: genPos(after) else: "0",
|
||||
"reset_error_state": "false"
|
||||
}
|
||||
param = genQueryParam(query)
|
||||
encoded = encodeUrl(param, usePlus=false)
|
||||
|
||||
headers = newHttpHeaders({
|
||||
"Accept": jsonAccept,
|
||||
"Referer": $(base / ("search?f=$1&q=$2&src=typd" % [kind, encoded])),
|
||||
"User-Agent": agent,
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"Authority": "twitter.com",
|
||||
"Accept-Language": lang
|
||||
})
|
||||
|
||||
params = {
|
||||
"f": kind,
|
||||
"vertical": "default",
|
||||
"q": param,
|
||||
"src": "typd",
|
||||
"include_available_features": "1",
|
||||
"include_entities": "1",
|
||||
"max_position": if pos.len > 0: pos else: "0",
|
||||
"reset_error_state": "false"
|
||||
}
|
||||
|
||||
let json = await fetchJson(base / searchUrl ? params, headers)
|
||||
result = await finishTimeline(json, some(query), after, agent)
|
||||
if json == nil: return Result[T](query: query, beginning: true)
|
||||
|
||||
result = getResult[T](json, query, after)
|
||||
if not json.hasKey("items_html"): return
|
||||
let html = parseHtml(json["items_html"].to(string))
|
||||
|
||||
when T is Tweet:
|
||||
result = await finishTimeline(json, query, after, agent)
|
||||
elif T is Profile:
|
||||
result.hasMore = json["items_html"].to(string) != "\n"
|
||||
for p in html.selectAll(".js-stream-item"):
|
||||
result.content.add parsePopupProfile(p, ".ProfileCard")
|
||||
|
|
|
@ -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()
|
||||
|
|
127
src/query.nim
Normal file
127
src/query.nim
Normal file
|
@ -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"))))
|
||||
|
|
30
src/routes/search.nim
Normal file
30
src/routes/search.nim
Normal file
|
@ -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,27 +1,25 @@
|
|||
@import '_variables';
|
||||
|
||||
nav {
|
||||
z-index: 1000;
|
||||
background-color: $bg_overlays;
|
||||
box-shadow: 0 0 4px $shadow;
|
||||
}
|
||||
|
||||
.nav-bar {
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: fixed;
|
||||
background-color: $bg_overlays;
|
||||
box-shadow: 0 0 4px $shadow;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.inner-nav {
|
||||
margin: auto;
|
||||
box-sizing: border-box;
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-basis: 920px;
|
||||
height: 50px;
|
||||
}
|
||||
.inner-nav {
|
||||
margin: auto;
|
||||
box-sizing: border-box;
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-basis: 920px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.site-name {
|
||||
|
@ -39,7 +37,7 @@ nav {
|
|||
height: 35px;
|
||||
}
|
||||
|
||||
.item {
|
||||
.nav-item {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
line-height: 50px;
|
||||
|
|
|
@ -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;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
@include create-toggle(photo-rail-grid, 640px);
|
||||
#photo-rail-grid-toggle:checked ~ .photo-rail-grid {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
@media(max-width: 600px) {
|
||||
|
@ -72,7 +64,7 @@
|
|||
}
|
||||
|
||||
.photo-rail-header-mobile {
|
||||
display: block;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.photo-rail-grid {
|
||||
|
@ -82,3 +74,23 @@
|
|||
transition: max-height 0.4s;
|
||||
}
|
||||
}
|
||||
|
||||
@media(max-width: 600px) {
|
||||
.photo-rail-grid {
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
|
||||
#photo-rail-grid-toggle:checked ~ .photo-rail-grid {
|
||||
max-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
@media(max-width: 450px) {
|
||||
.photo-rail-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
#photo-rail-grid-toggle:checked ~ .photo-rail-grid {
|
||||
max-height: 450px;
|
||||
}
|
||||
}
|
||||
|
|
120
src/sass/search.scss
Normal file
120
src/sass/search.scss
Normal file
|
@ -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: unset;
|
||||
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,11 +54,10 @@ proc renderPhotoRail(profile: Profile; photoRail: seq[GalleryPhoto]): VNode =
|
|||
a(href=(&"/{profile.username}/media")):
|
||||
icon "picture", $profile.media & " Photos and videos"
|
||||
|
||||
input(id="photo-rail-toggle", `type`="checkbox")
|
||||
tdiv(class="photo-rail-header-mobile"):
|
||||
label(`for`="photo-rail-toggle", class="photo-rail-label"):
|
||||
icon "picture", $profile.media & " Photos and videos"
|
||||
icon "down"
|
||||
input(id="photo-rail-grid-toggle", `type`="checkbox")
|
||||
label(`for`="photo-rail-grid-toggle", class="photo-rail-header-mobile"):
|
||||
icon "picture", $profile.media & " Photos and videos"
|
||||
icon "down"
|
||||
|
||||
tdiv(class="photo-rail-grid"):
|
||||
for i, photo in photoRail:
|
||||
|
@ -75,8 +74,15 @@ proc renderBanner(profile: Profile): VNode =
|
|||
a(href=getPicUrl(profile.banner), target="_blank"):
|
||||
genImg(profile.banner)
|
||||
|
||||
proc renderProtected(username: string): VNode =
|
||||
buildHtml(tdiv(class="timeline-container")):
|
||||
tdiv(class="timeline-header timeline-protected"):
|
||||
h2: text "This account's tweets are protected."
|
||||
p: text &"Only confirmed followers have access to @{username}'s tweets."
|
||||
|
||||
proc renderProfile*(profile: Profile; timeline: Timeline;
|
||||
photoRail: seq[GalleryPhoto]; prefs: Prefs; path: string): VNode =
|
||||
timeline.query.fromUser = @[profile.username]
|
||||
buildHtml(tdiv(class="profile-tabs")):
|
||||
if not prefs.hideBanner:
|
||||
tdiv(class="profile-banner"):
|
||||
|
@ -88,11 +94,7 @@ proc renderProfile*(profile: Profile; timeline: Timeline;
|
|||
if photoRail.len > 0:
|
||||
renderPhotoRail(profile, photoRail)
|
||||
|
||||
tdiv(class="timeline-tab"):
|
||||
renderTimeline(timeline, profile.username, profile.protected, prefs, path)
|
||||
|
||||
proc renderMulti*(timeline: Timeline; usernames: string;
|
||||
prefs: Prefs; path: string): VNode =
|
||||
buildHtml(tdiv(class="multi-timeline")):
|
||||
tdiv(class="timeline-tab"):
|
||||
renderTimeline(timeline, usernames, false, prefs, path, multi=true)
|
||||
if profile.protected:
|
||||
renderProtected(profile.username)
|
||||
else:
|
||||
renderTweetSearch(timeline, prefs, path)
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import strutils
|
||||
import strutils, strformat, xmltree
|
||||
import karax/[karaxdsl, vdom]
|
||||
|
||||
import ../types, ../utils
|
||||
|
@ -39,9 +39,12 @@ proc linkText*(text: string; class=""): VNode =
|
|||
buildHtml():
|
||||
a(href=url, class=class): text text
|
||||
|
||||
proc refererField*(path: string): VNode =
|
||||
proc hiddenField*(name, value: string): VNode =
|
||||
buildHtml():
|
||||
verbatim "<input name=\"referer\" style=\"display: none\" value=\"$1\"/>" % path
|
||||
verbatim "<input name=\"$1\" style=\"display: none\" value=\"$2\"/>" % [name, value]
|
||||
|
||||
proc refererField*(path: string): VNode =
|
||||
hiddenField("referer", path)
|
||||
|
||||
proc iconReferer*(icon, action, path: string, title=""): VNode =
|
||||
buildHtml(form(`method`="get", action=action, class="icon-button")):
|
||||
|
@ -54,3 +57,37 @@ proc buttonReferer*(action, text, path: string; class=""; `method`="post"): VNod
|
|||
refererField path
|
||||
button(`type`="submit"):
|
||||
text text
|
||||
|
||||
proc genCheckbox*(pref, label: string; state: bool): VNode =
|
||||
buildHtml(label(class="pref-group checkbox-container")):
|
||||
text label
|
||||
if state: input(name=pref, `type`="checkbox", checked="")
|
||||
else: input(name=pref, `type`="checkbox")
|
||||
span(class="checkbox")
|
||||
|
||||
proc genInput*(pref, label, state, placeholder: string; class=""; autofocus=false): VNode =
|
||||
let s = xmltree.escape(state)
|
||||
let p = xmltree.escape(placeholder)
|
||||
let a = if autofocus: "autofocus" else: ""
|
||||
buildHtml(tdiv(class=("pref-group pref-input " & class))):
|
||||
if label.len > 0:
|
||||
label(`for`=pref): text label
|
||||
verbatim &"<input name={pref} type=\"text\" placeholder=\"{p}\" value=\"{s}\" {a}/>"
|
||||
|
||||
proc genSelect*(pref, label, state: string; options: seq[string]): VNode =
|
||||
buildHtml(tdiv(class="pref-group")):
|
||||
label(`for`=pref): text label
|
||||
select(name=pref):
|
||||
for opt in options:
|
||||
if opt == state:
|
||||
option(value=opt, selected=""): text opt
|
||||
else:
|
||||
option(value=opt): text opt
|
||||
|
||||
proc genDate*(pref, state: string): VNode =
|
||||
buildHtml(span(class="date-input")):
|
||||
if state.len > 0:
|
||||
verbatim &"<input name={pref} type=\"date\" value=\"{state}\"/>"
|
||||
else:
|
||||
verbatim &"<input name={pref} type=\"date\"/>"
|
||||
icon "calendar"
|
||||
|
|
123
src/views/search.nim
Normal file
123
src/views/search.nim
Normal file
|
@ -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")):
|
||||
var threads: seq[string]
|
||||
for tweet in timeline.content:
|
||||
if tweet.threadId in threads: continue
|
||||
let thread = timeline.content.filterIt(threadFilter(it, tweet.threadId))
|
||||
if thread.len < 2:
|
||||
renderTweet(tweet, prefs, path, class="timeline-tweet")
|
||||
else:
|
||||
renderThread(thread, prefs, path)
|
||||
threads &= tweet.threadId
|
||||
proc renderUser(user: Profile; prefs: Prefs): VNode =
|
||||
buildHtml(tdiv(class="timeline-item")):
|
||||
a(class="tweet-link", href=("/" & user.username))
|
||||
tdiv(class="tweet-body profile-result"):
|
||||
tdiv(class="tweet-header"):
|
||||
a(class="tweet-avatar", href=("/" & user.username)):
|
||||
genImg(user.getUserpic("_bigger"), class="avatar")
|
||||
|
||||
proc renderTimeline*(timeline: Timeline; username: string; protected: bool;
|
||||
prefs: Prefs; path: string; multi=false): VNode =
|
||||
buildHtml(tdiv):
|
||||
if multi:
|
||||
tdiv(class="multi-header"):
|
||||
text username.replace(",", " | ")
|
||||
tdiv(class="tweet-name-row"):
|
||||
tdiv(class="fullname-and-username"):
|
||||
linkUser(user, class="fullname")
|
||||
linkUser(user, class="username")
|
||||
|
||||
if not protected:
|
||||
renderSearchTabs(timeline, username)
|
||||
if not timeline.beginning:
|
||||
renderNewer(timeline, username)
|
||||
tdiv(class="tweet-content media-body"):
|
||||
verbatim linkifyText(user.bio, prefs)
|
||||
|
||||
if protected:
|
||||
renderProtected(username)
|
||||
elif timeline.content.len == 0:
|
||||
proc renderTimelineUsers*(results: Result[Profile]; prefs: Prefs): VNode =
|
||||
buildHtml(tdiv(class="timeline")):
|
||||
if not results.beginning:
|
||||
renderNewer(results.query)
|
||||
|
||||
if results.content.len > 0:
|
||||
for user in results.content:
|
||||
renderUser(user, prefs)
|
||||
renderOlder(results.query, results.minId)
|
||||
elif results.beginning:
|
||||
renderNoneFound()
|
||||
else:
|
||||
renderTweets(timeline, prefs, path)
|
||||
if timeline.hasMore or timeline.query.isSome:
|
||||
renderOlder(timeline, username)
|
||||
renderNoMore()
|
||||
|
||||
proc renderTimelineTweets*(results: Result[Tweet]; prefs: Prefs; path: string): VNode =
|
||||
buildHtml(tdiv(class="timeline")):
|
||||
if not results.beginning:
|
||||
renderNewer(results.query)
|
||||
|
||||
if results.content.len == 0:
|
||||
renderNoneFound()
|
||||
else:
|
||||
var threads: seq[string]
|
||||
var retweets: seq[string]
|
||||
for tweet in results.content:
|
||||
if tweet.threadId in threads or tweet.id in retweets: continue
|
||||
let thread = results.content.filterIt(threadFilter(it, tweet.threadId))
|
||||
if thread.len < 2:
|
||||
if tweet.retweet.isSome:
|
||||
retweets &= tweet.id
|
||||
renderTweet(tweet, prefs, path, showThread=tweet.hasThread)
|
||||
else:
|
||||
renderThread(thread, prefs, path)
|
||||
threads &= tweet.threadId
|
||||
|
||||
if results.hasMore or results.query.kind != posts:
|
||||
renderOlder(results.query, results.minId)
|
||||
else:
|
||||
renderNoMore()
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import strutils, sequtils
|
||||
import strutils, sequtils, strformat
|
||||
import karax/[karaxdsl, vdom, vstyles]
|
||||
|
||||
import renderutils
|
||||
|
@ -31,19 +31,24 @@ proc renderAlbum(tweet: Tweet): VNode =
|
|||
let
|
||||
groups = if tweet.photos.len < 3: @[tweet.photos]
|
||||
else: tweet.photos.distribute(2)
|
||||
class = if groups.len == 1 and groups[0].len == 1: "single-image"
|
||||
else: ""
|
||||
|
||||
buildHtml(tdiv(class=("attachments " & class))):
|
||||
for i, photos in groups:
|
||||
let margin = if i > 0: ".25em" else: ""
|
||||
let flex = if photos.len > 1 or groups.len > 1: "flex" else: "block"
|
||||
tdiv(class="gallery-row", style={marginTop: margin}):
|
||||
for photo in photos:
|
||||
tdiv(class="attachment image"):
|
||||
a(href=getPicUrl(photo & "?name=orig"), class="still-image",
|
||||
target="_blank", style={display: flex}):
|
||||
genImg(photo)
|
||||
if groups.len == 1 and groups[0].len == 1:
|
||||
buildHtml(tdiv(class="single-image")):
|
||||
tdiv(class="attachments gallery-row"):
|
||||
a(href=getPicUrl(groups[0][0] & "?name=orig"), class="still-image",
|
||||
target="_blank"):
|
||||
genImg(groups[0][0])
|
||||
else:
|
||||
buildHtml(tdiv(class="attachments")):
|
||||
for i, photos in groups:
|
||||
let margin = if i > 0: ".25em" else: ""
|
||||
let flex = if photos.len > 1 or groups.len > 1: "flex" else: "block"
|
||||
tdiv(class="gallery-row", style={marginTop: margin}):
|
||||
for photo in photos:
|
||||
tdiv(class="attachment image"):
|
||||
a(href=getPicUrl(photo & "?name=orig"), class="still-image",
|
||||
target="_blank", style={display: flex}):
|
||||
genImg(photo)
|
||||
|
||||
proc isPlaybackEnabled(prefs: Prefs; video: Video): bool =
|
||||
case video.playbackType
|
||||
|
@ -217,50 +222,49 @@ proc renderQuote(quote: Quote; prefs: Prefs): VNode =
|
|||
text "Show this thread"
|
||||
|
||||
proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class="";
|
||||
index=0; total=(-1); last=false): VNode =
|
||||
index=0; total=(-1); last=false; showThread=false): VNode =
|
||||
var divClass = class
|
||||
if index == total or last:
|
||||
divClass = "thread-last " & class
|
||||
|
||||
if not tweet.available:
|
||||
return buildHtml(tdiv(class=divClass)):
|
||||
tdiv(class="status-el unavailable"):
|
||||
tdiv(class="unavailable-box"):
|
||||
if tweet.tombstone.len > 0:
|
||||
text tweet.tombstone
|
||||
else:
|
||||
text "This tweet is unavailable"
|
||||
return buildHtml(tdiv(class=divClass & "unavailable timeline-item")):
|
||||
tdiv(class="unavailable-box"):
|
||||
if tweet.tombstone.len > 0:
|
||||
text tweet.tombstone
|
||||
else:
|
||||
text "This tweet is unavailable"
|
||||
|
||||
buildHtml(tdiv(class=divClass)):
|
||||
tdiv(class="status-el"):
|
||||
tdiv(class="status-body"):
|
||||
var views = ""
|
||||
renderHeader(tweet)
|
||||
buildHtml(tdiv(class=("timeline-item " & divClass))):
|
||||
a(class="tweet-link", href=getLink(tweet))
|
||||
tdiv(class="tweet-body"):
|
||||
var views = ""
|
||||
renderHeader(tweet)
|
||||
|
||||
if index == 0 and tweet.reply.len > 0:
|
||||
renderReply(tweet)
|
||||
if index == 0 and tweet.reply.len > 0:
|
||||
renderReply(tweet)
|
||||
|
||||
tdiv(class="status-content media-body"):
|
||||
verbatim linkifyText(tweet.text, prefs)
|
||||
tdiv(class="tweet-content media-body"):
|
||||
verbatim linkifyText(tweet.text, prefs)
|
||||
|
||||
if tweet.quote.isSome:
|
||||
renderQuote(tweet.quote.get(), prefs)
|
||||
if tweet.quote.isSome:
|
||||
renderQuote(tweet.quote.get(), prefs)
|
||||
|
||||
if tweet.card.isSome:
|
||||
renderCard(tweet.card.get(), prefs, path)
|
||||
elif tweet.photos.len > 0:
|
||||
renderAlbum(tweet)
|
||||
elif tweet.video.isSome:
|
||||
renderVideo(tweet.video.get(), prefs, path)
|
||||
views = tweet.video.get().views
|
||||
elif tweet.gif.isSome:
|
||||
renderGif(tweet.gif.get(), prefs)
|
||||
elif tweet.poll.isSome:
|
||||
renderPoll(tweet.poll.get())
|
||||
if tweet.card.isSome:
|
||||
renderCard(tweet.card.get(), prefs, path)
|
||||
elif tweet.photos.len > 0:
|
||||
renderAlbum(tweet)
|
||||
elif tweet.video.isSome:
|
||||
renderVideo(tweet.video.get(), prefs, path)
|
||||
views = tweet.video.get().views
|
||||
elif tweet.gif.isSome:
|
||||
renderGif(tweet.gif.get(), prefs)
|
||||
elif tweet.poll.isSome:
|
||||
renderPoll(tweet.poll.get())
|
||||
|
||||
if not prefs.hideTweetStats:
|
||||
renderStats(tweet.stats, views)
|
||||
if not prefs.hideTweetStats:
|
||||
renderStats(tweet.stats, views)
|
||||
|
||||
if tweet.hasThread and "timeline" in class:
|
||||
a(class="show-thread", href=getLink(tweet)):
|
||||
text "Show this thread"
|
||||
if showThread:
|
||||
a(class="show-thread", href=getLink(tweet)):
|
||||
text "Show this thread"
|
||||
|
|
|
@ -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 a new issue