Merge pull request #39 from zedeus/search

Search
This commit is contained in:
Zed 2019-09-20 13:51:52 +02:00 committed by GitHub
commit 6b437d5f87
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
44 changed files with 951 additions and 463 deletions

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 910 KiB

After

Width:  |  Height:  |  Size: 912 KiB

View file

@ -1,2 +1,2 @@
import api/[media, profile, timeline, tweet, search]
import api/[profile, timeline, tweet, search, media]
export profile, timeline, tweet, search, media

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
import strutils, strformat, htmlgen, xmltree, times
import strutils, strformat, sequtils, htmlgen, xmltree, times, uri
import regex
import types, utils
@ -8,9 +8,10 @@ from unicode import Rune, `$`
const
urlRegex = re"((https?|ftp)://(-\.)?([^\s/?\.#]+\.?)+([/\?][^\s\)]*)?)"
emailRegex = re"([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)"
usernameRegex = re"(^|[^A-z0-9_?])@([A-z0-9_]+)"
usernameRegex = re"(^|[^A-z0-9_?\/])@([A-z0-9_]+)"
picRegex = re"pic.twitter.com/[^ ]+"
ellipsisRegex = re" ?…"
hashtagRegex = re"([^\S])?([#$][A-z0-9]+)"
ytRegex = re"(www.|m.)?youtu(be.com|.be)"
twRegex = re"(www.|mobile.)?twitter.com"
nbsp = $Rune(0x000A0)
@ -40,6 +41,15 @@ proc reEmailToLink*(m: RegexMatch; s: string): string =
let url = s[m.group(0)[0]]
toLink("mailto://" & url, url)
proc reHashtagToLink*(m: RegexMatch; s: string): string =
result = if m.group(0).len > 0: s[m.group(0)[0]] else: ""
let hash = s[m.group(1)[0]]
let link = toLink("/search?text=" & encodeUrl(hash), hash)
if hash.any(isAlphaAscii):
result &= link
else:
result &= hash
proc reUsernameToLink*(m: RegexMatch; s: string): string =
var username = ""
var pretext = ""
@ -67,7 +77,7 @@ proc replaceUrl*(url: string; prefs: Prefs): string =
proc linkifyText*(text: string; prefs: Prefs; rss=false): string =
result = xmltree.escape(stripText(text))
result = result.replace(ellipsisRegex, "")
result = result.replace(ellipsisRegex, " ")
result = result.replace(emailRegex, reEmailToLink)
if rss:
result = result.replace(urlRegex, reUrlToLink)
@ -75,6 +85,7 @@ proc linkifyText*(text: string; prefs: Prefs; rss=false): string =
else:
result = result.replace(urlRegex, reUrlToShortLink)
result = result.replace(usernameRegex, reUsernameToLink)
result = result.replace(hashtagRegex, reHashtagToLink)
result = result.replace(re"([^\s\(\n%])<a", "$1 <a")
result = result.replace(re"</a>\s+([;.,!\)'%]|&apos;)", "</a>$1")
result = result.replace(re"^\. <a", ".<a")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -10,7 +10,7 @@
@include center-panel($error_red);
}
.search-panel > form {
.search-bar > form {
@include center-panel($darkest-grey);
button {

View file

@ -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);
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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
View 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);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"):

View file

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

View file

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

View file

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

View file

@ -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"):

View file

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

View file

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

View file

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

View file

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

View file

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