commit
6b437d5f87
|
@ -65,8 +65,6 @@ Then enable and run the service:
|
||||||
|
|
||||||
## Todo (roughly in this order)
|
## 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))
|
- More caching (waiting for [moigagoo/norm#19](https://github.com/moigagoo/norm/pull/19))
|
||||||
- Simple account system with customizable feed
|
- Simple account system with customizable feed
|
||||||
- Json API endpoints
|
- 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
|
export profile, timeline, tweet, search, media
|
||||||
|
|
|
@ -89,10 +89,10 @@ proc getVideoFetch(tweet: Tweet; agent, token: string) {.async.} =
|
||||||
return
|
return
|
||||||
|
|
||||||
if tweet.card.isNone:
|
if tweet.card.isNone:
|
||||||
tweet.video = some(parseVideo(json, tweet.id))
|
tweet.video = some parseVideo(json, tweet.id)
|
||||||
else:
|
else:
|
||||||
get(tweet.card).video = some(parseVideo(json, tweet.id))
|
get(tweet.card).video = some parseVideo(json, tweet.id)
|
||||||
tweet.video = none(Video)
|
tweet.video = none Video
|
||||||
tokenUses.inc
|
tokenUses.inc
|
||||||
|
|
||||||
proc getVideoVar(tweet: Tweet): var Option[Video] =
|
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.} =
|
proc getVideo*(tweet: Tweet; agent, token: string; force=false) {.async.} =
|
||||||
withCustomDb("cache.db", "", "", ""):
|
withCustomDb("cache.db", "", "", ""):
|
||||||
try:
|
try:
|
||||||
getVideoVar(tweet) = some(Video.getOne("videoId = ?", tweet.id))
|
getVideoVar(tweet) = some Video.getOne("videoId = ?", tweet.id)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
await getVideoFetch(tweet, agent, token)
|
await getVideoFetch(tweet, agent, token)
|
||||||
var video = getVideoVar(tweet)
|
var video = getVideoVar(tweet)
|
||||||
|
@ -126,7 +126,7 @@ proc getPoll*(tweet: Tweet; agent: string) {.async.} =
|
||||||
let html = await fetchHtml(url, headers)
|
let html = await fetchHtml(url, headers)
|
||||||
if html == nil: return
|
if html == nil: return
|
||||||
|
|
||||||
tweet.poll = some(parsePoll(html))
|
tweet.poll = some parsePoll(html)
|
||||||
|
|
||||||
proc getCard*(tweet: Tweet; agent: string) {.async.} =
|
proc getCard*(tweet: Tweet; agent: string) {.async.} =
|
||||||
if tweet.card.isNone(): return
|
if tweet.card.isNone(): return
|
||||||
|
|
|
@ -1,32 +1,56 @@
|
||||||
import httpclient, asyncdispatch, htmlparser
|
import httpclient, asyncdispatch, htmlparser
|
||||||
import sequtils, strutils, json, xmltree, uri
|
import strutils, json, xmltree, uri
|
||||||
|
|
||||||
import ".."/[types, parser, parserutils, formatters, search]
|
import ".."/[types, parser, parserutils, query]
|
||||||
import utils, consts, media, timeline
|
import utils, consts, timeline
|
||||||
|
|
||||||
proc getTimelineSearch*(query: Query; after, agent: string): Future[Timeline] {.async.} =
|
proc getResult[T](json: JsonNode; query: Query; after: string): Result[T] =
|
||||||
let queryParam = genQueryParam(query)
|
Result[T](
|
||||||
let queryEncoded = encodeUrl(queryParam, usePlus=false)
|
hasMore: json["has_more_items"].to(bool),
|
||||||
|
maxId: json.getOrDefault("max_position").getStr(""),
|
||||||
|
minId: json.getOrDefault("min_position").getStr("").cleanPos(),
|
||||||
|
query: query,
|
||||||
|
beginning: after.len == 0
|
||||||
|
)
|
||||||
|
|
||||||
let headers = newHttpHeaders({
|
proc getSearch*[T](query: Query; after, agent: string): Future[Result[T]] {.async.} =
|
||||||
|
let
|
||||||
|
kind = if query.kind == users: "users" else: "tweets"
|
||||||
|
pos = when T is Tweet: genPos(after) else: after
|
||||||
|
|
||||||
|
param = genQueryParam(query)
|
||||||
|
encoded = encodeUrl(param, usePlus=false)
|
||||||
|
|
||||||
|
headers = newHttpHeaders({
|
||||||
"Accept": jsonAccept,
|
"Accept": jsonAccept,
|
||||||
"Referer": $(base / ("search?f=tweets&vertical=default&q=$1&src=typd" % queryEncoded)),
|
"Referer": $(base / ("search?f=$1&q=$2&src=typd" % [kind, encoded])),
|
||||||
"User-Agent": agent,
|
"User-Agent": agent,
|
||||||
"X-Requested-With": "XMLHttpRequest",
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
"Authority": "twitter.com",
|
"Authority": "twitter.com",
|
||||||
"Accept-Language": lang
|
"Accept-Language": lang
|
||||||
})
|
})
|
||||||
|
|
||||||
let params = {
|
params = {
|
||||||
"f": "tweets",
|
"f": kind,
|
||||||
"vertical": "default",
|
"vertical": "default",
|
||||||
"q": queryParam,
|
"q": param,
|
||||||
"src": "typd",
|
"src": "typd",
|
||||||
"include_available_features": "1",
|
"include_available_features": "1",
|
||||||
"include_entities": "1",
|
"include_entities": "1",
|
||||||
"max_position": if after.len > 0: genPos(after) else: "0",
|
"max_position": if pos.len > 0: pos else: "0",
|
||||||
"reset_error_state": "false"
|
"reset_error_state": "false"
|
||||||
}
|
}
|
||||||
|
|
||||||
let json = await fetchJson(base / searchUrl ? params, headers)
|
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 httpclient, asyncdispatch, htmlparser
|
||||||
import sequtils, strutils, json, xmltree, uri
|
import sequtils, strutils, json, xmltree, uri
|
||||||
|
|
||||||
import ".."/[types, parser, parserutils, formatters, search]
|
import ".."/[types, parser, parserutils, formatters, query]
|
||||||
import utils, consts, media
|
import utils, consts, media
|
||||||
|
|
||||||
proc finishTimeline*(json: JsonNode; query: Option[Query]; after, agent: string): Future[Timeline] {.async.} =
|
proc finishTimeline*(json: JsonNode; query: Query; after, agent: string): Future[Timeline] {.async.} =
|
||||||
if json == nil: return Timeline()
|
if json == nil: return Timeline(beginning: true, query: query)
|
||||||
|
|
||||||
result = Timeline(
|
result = Timeline(
|
||||||
hasMore: json["has_more_items"].to(bool),
|
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}
|
params.add {"max_position": after}
|
||||||
|
|
||||||
let json = await fetchJson(base / (timelineUrl % username) ? params, headers)
|
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.} =
|
proc getProfileAndTimeline*(username, agent, after: string): Future[(Profile, Timeline)] {.async.} =
|
||||||
let headers = newHttpHeaders({
|
let headers = newHttpHeaders({
|
||||||
|
|
|
@ -29,9 +29,9 @@ proc hasCachedProfile*(username: string): Option[Profile] =
|
||||||
try:
|
try:
|
||||||
let p = Profile.getOne("lower(username) = ?", toLower(username))
|
let p = Profile.getOne("lower(username) = ?", toLower(username))
|
||||||
doAssert not p.isOutdated
|
doAssert not p.isOutdated
|
||||||
result = some(p)
|
result = some p
|
||||||
except AssertionError, KeyError:
|
except AssertionError, KeyError:
|
||||||
result = none(Profile)
|
result = none Profile
|
||||||
|
|
||||||
proc getCachedProfile*(username, agent: string; force=false): Future[Profile] {.async.} =
|
proc getCachedProfile*(username, agent: string; force=false): Future[Profile] {.async.} =
|
||||||
withDb:
|
withDb:
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import strutils, strformat, htmlgen, xmltree, times
|
import strutils, strformat, sequtils, htmlgen, xmltree, times, uri
|
||||||
import regex
|
import regex
|
||||||
|
|
||||||
import types, utils
|
import types, utils
|
||||||
|
@ -8,9 +8,10 @@ from unicode import Rune, `$`
|
||||||
const
|
const
|
||||||
urlRegex = re"((https?|ftp)://(-\.)?([^\s/?\.#]+\.?)+([/\?][^\s\)]*)?)"
|
urlRegex = re"((https?|ftp)://(-\.)?([^\s/?\.#]+\.?)+([/\?][^\s\)]*)?)"
|
||||||
emailRegex = re"([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)"
|
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/[^ ]+"
|
picRegex = re"pic.twitter.com/[^ ]+"
|
||||||
ellipsisRegex = re" ?…"
|
ellipsisRegex = re" ?…"
|
||||||
|
hashtagRegex = re"([^\S])?([#$][A-z0-9]+)"
|
||||||
ytRegex = re"(www.|m.)?youtu(be.com|.be)"
|
ytRegex = re"(www.|m.)?youtu(be.com|.be)"
|
||||||
twRegex = re"(www.|mobile.)?twitter.com"
|
twRegex = re"(www.|mobile.)?twitter.com"
|
||||||
nbsp = $Rune(0x000A0)
|
nbsp = $Rune(0x000A0)
|
||||||
|
@ -40,6 +41,15 @@ proc reEmailToLink*(m: RegexMatch; s: string): string =
|
||||||
let url = s[m.group(0)[0]]
|
let url = s[m.group(0)[0]]
|
||||||
toLink("mailto://" & url, url)
|
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 =
|
proc reUsernameToLink*(m: RegexMatch; s: string): string =
|
||||||
var username = ""
|
var username = ""
|
||||||
var pretext = ""
|
var pretext = ""
|
||||||
|
@ -67,7 +77,7 @@ proc replaceUrl*(url: string; prefs: Prefs): string =
|
||||||
|
|
||||||
proc linkifyText*(text: string; prefs: Prefs; rss=false): string =
|
proc linkifyText*(text: string; prefs: Prefs; rss=false): string =
|
||||||
result = xmltree.escape(stripText(text))
|
result = xmltree.escape(stripText(text))
|
||||||
result = result.replace(ellipsisRegex, "")
|
result = result.replace(ellipsisRegex, " ")
|
||||||
result = result.replace(emailRegex, reEmailToLink)
|
result = result.replace(emailRegex, reEmailToLink)
|
||||||
if rss:
|
if rss:
|
||||||
result = result.replace(urlRegex, reUrlToLink)
|
result = result.replace(urlRegex, reUrlToLink)
|
||||||
|
@ -75,6 +85,7 @@ proc linkifyText*(text: string; prefs: Prefs; rss=false): string =
|
||||||
else:
|
else:
|
||||||
result = result.replace(urlRegex, reUrlToShortLink)
|
result = result.replace(urlRegex, reUrlToShortLink)
|
||||||
result = result.replace(usernameRegex, reUsernameToLink)
|
result = result.replace(usernameRegex, reUsernameToLink)
|
||||||
|
result = result.replace(hashtagRegex, reHashtagToLink)
|
||||||
result = result.replace(re"([^\s\(\n%])<a", "$1 <a")
|
result = result.replace(re"([^\s\(\n%])<a", "$1 <a")
|
||||||
result = result.replace(re"</a>\s+([;.,!\)'%]|')", "</a>$1")
|
result = result.replace(re"</a>\s+([;.,!\)'%]|')", "</a>$1")
|
||||||
result = result.replace(re"^\. <a", ".<a")
|
result = result.replace(re"^\. <a", ".<a")
|
||||||
|
|
|
@ -5,13 +5,14 @@ import jester
|
||||||
|
|
||||||
import types, config, prefs
|
import types, config, prefs
|
||||||
import views/[general, about]
|
import views/[general, about]
|
||||||
import routes/[preferences, timeline, media, rss]
|
import routes/[preferences, timeline, media, search, rss]
|
||||||
|
|
||||||
const configPath {.strdefine.} = "./nitter.conf"
|
const configPath {.strdefine.} = "./nitter.conf"
|
||||||
let cfg = getConfig(configPath)
|
let cfg = getConfig(configPath)
|
||||||
|
|
||||||
createPrefRouter(cfg)
|
createPrefRouter(cfg)
|
||||||
createTimelineRouter(cfg)
|
createTimelineRouter(cfg)
|
||||||
|
createSearchRouter(cfg)
|
||||||
createMediaRouter(cfg)
|
createMediaRouter(cfg)
|
||||||
createRssRouter(cfg)
|
createRssRouter(cfg)
|
||||||
|
|
||||||
|
@ -27,13 +28,9 @@ routes:
|
||||||
get "/about":
|
get "/about":
|
||||||
resp renderMain(renderAbout(), Prefs(), cfg.title)
|
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 preferences, ""
|
||||||
extend rss, ""
|
extend rss, ""
|
||||||
|
extend search, ""
|
||||||
extend timeline, ""
|
extend timeline, ""
|
||||||
extend media, ""
|
extend media, ""
|
||||||
|
|
||||||
|
|
|
@ -23,14 +23,14 @@ proc parseTimelineProfile*(node: XmlNode): Profile =
|
||||||
|
|
||||||
result.getProfileStats(node.select(".ProfileNav-list"))
|
result.getProfileStats(node.select(".ProfileNav-list"))
|
||||||
|
|
||||||
proc parsePopupProfile*(node: XmlNode): Profile =
|
proc parsePopupProfile*(node: XmlNode; selector=".profile-card"): Profile =
|
||||||
let profile = node.select(".profile-card")
|
let profile = node.select(selector)
|
||||||
if profile == nil: return
|
if profile == nil: return
|
||||||
|
|
||||||
result = Profile(
|
result = Profile(
|
||||||
fullname: profile.getName(".fullname"),
|
fullname: profile.getName(".fullname"),
|
||||||
username: profile.getUsername(".username"),
|
username: profile.getUsername(".username"),
|
||||||
bio: profile.getBio(".bio"),
|
bio: profile.getBio(".bio", fallback=".ProfileCard-bio"),
|
||||||
userpic: profile.getAvatar(".ProfileCard-avatarImage"),
|
userpic: profile.getAvatar(".ProfileCard-avatarImage"),
|
||||||
verified: isVerified(profile),
|
verified: isVerified(profile),
|
||||||
protected: isProtected(profile),
|
protected: isProtected(profile),
|
||||||
|
@ -104,20 +104,20 @@ proc parseTweet*(node: XmlNode): Tweet =
|
||||||
|
|
||||||
let by = tweet.selectText(".js-retweet-text > a > b")
|
let by = tweet.selectText(".js-retweet-text > a > b")
|
||||||
if by.len > 0:
|
if by.len > 0:
|
||||||
result.retweet = some(Retweet(
|
result.retweet = some Retweet(
|
||||||
by: stripText(by),
|
by: stripText(by),
|
||||||
id: tweet.attr("data-retweet-id")
|
id: tweet.attr("data-retweet-id")
|
||||||
))
|
)
|
||||||
|
|
||||||
let quote = tweet.select(".QuoteTweet-innerContainer")
|
let quote = tweet.select(".QuoteTweet-innerContainer")
|
||||||
if quote != nil:
|
if quote != nil:
|
||||||
result.quote = some(parseQuote(quote))
|
result.quote = some parseQuote(quote)
|
||||||
|
|
||||||
let tombstone = tweet.select(".Tombstone")
|
let tombstone = tweet.select(".Tombstone")
|
||||||
if tombstone != nil:
|
if tombstone != nil:
|
||||||
if "unavailable" in tombstone.innerText():
|
if "unavailable" in tombstone.innerText():
|
||||||
let quote = Quote(tombstone: getTombstone(node.selectText(".Tombstone-label")))
|
let quote = Quote(tombstone: getTombstone(node.selectText(".Tombstone-label")))
|
||||||
result.quote = some(quote)
|
result.quote = some quote
|
||||||
|
|
||||||
proc parseThread*(nodes: XmlNode): Thread =
|
proc parseThread*(nodes: XmlNode): Thread =
|
||||||
if nodes == nil: return
|
if nodes == nil: return
|
||||||
|
@ -157,7 +157,7 @@ proc parseConversation*(node: XmlNode): Conversation =
|
||||||
result.replies.add parseThread(thread)
|
result.replies.add parseThread(thread)
|
||||||
|
|
||||||
proc parseTimeline*(node: XmlNode; after: string): Timeline =
|
proc parseTimeline*(node: XmlNode; after: string): Timeline =
|
||||||
if node == nil: return
|
if node == nil: return Timeline()
|
||||||
result = Timeline(
|
result = Timeline(
|
||||||
content: parseThread(node.select(".stream > .stream-items")).content,
|
content: parseThread(node.select(".stream > .stream-items")).content,
|
||||||
minId: node.attr("data-min-position"),
|
minId: node.attr("data-min-position"),
|
||||||
|
@ -234,7 +234,7 @@ proc parseCard*(card: var Card; node: XmlNode) =
|
||||||
let image = node.select(".tcu-imageWrapper img")
|
let image = node.select(".tcu-imageWrapper img")
|
||||||
if image != nil:
|
if image != nil:
|
||||||
# workaround for issue 11713
|
# 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:
|
if card.kind == liveEvent:
|
||||||
card.text = card.title
|
card.text = card.title
|
||||||
|
|
|
@ -86,8 +86,11 @@ proc getName*(profile: XmlNode; selector: string): string =
|
||||||
proc getUsername*(profile: XmlNode; selector: string): string =
|
proc getUsername*(profile: XmlNode; selector: string): string =
|
||||||
profile.selectText(selector).strip(chars={'@', ' ', '\n'})
|
profile.selectText(selector).strip(chars={'@', ' ', '\n'})
|
||||||
|
|
||||||
proc getBio*(profile: XmlNode; selector: string): string =
|
proc getBio*(profile: XmlNode; selector: string; fallback=""): string =
|
||||||
profile.selectText(selector).stripText()
|
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 =
|
proc getAvatar*(profile: XmlNode; selector: string): string =
|
||||||
profile.selectAttr(selector, "src").getUserpic()
|
profile.selectAttr(selector, "src").getUserpic()
|
||||||
|
@ -177,9 +180,9 @@ proc getTweetMedia*(tweet: Tweet; node: XmlNode) =
|
||||||
if player == nil: return
|
if player == nil: return
|
||||||
|
|
||||||
if "gif" in player.attr("class"):
|
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"):
|
elif "video" in player.attr("class"):
|
||||||
tweet.video = some(Video())
|
tweet.video = some Video()
|
||||||
|
|
||||||
proc getQuoteMedia*(quote: var Quote; node: XmlNode) =
|
proc getQuoteMedia*(quote: var Quote; node: XmlNode) =
|
||||||
if node.select(".QuoteTweet--sensitive") != nil:
|
if node.select(".QuoteTweet--sensitive") != nil:
|
||||||
|
@ -206,7 +209,7 @@ proc getTweetCard*(tweet: Tweet; node: XmlNode) =
|
||||||
cardType = cardType.split(":")[^1]
|
cardType = cardType.split(":")[^1]
|
||||||
|
|
||||||
if "poll" in cardType:
|
if "poll" in cardType:
|
||||||
tweet.poll = some(Poll())
|
tweet.poll = some Poll()
|
||||||
return
|
return
|
||||||
|
|
||||||
let cardDiv = node.select(".card2 > .js-macaw-cards-iframe-container")
|
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:
|
if n.attr("href") == cardUrl:
|
||||||
card.url = n.attr("data-expanded-url")
|
card.url = n.attr("data-expanded-url")
|
||||||
|
|
||||||
tweet.card = some(card)
|
tweet.card = some card
|
||||||
|
|
||||||
proc getMoreReplies*(node: XmlNode): int =
|
proc getMoreReplies*(node: XmlNode): int =
|
||||||
let text = node.innerText().strip()
|
let text = node.innerText().strip()
|
||||||
|
|
|
@ -0,0 +1,127 @@
|
||||||
|
import strutils, strformat, sequtils, tables, uri
|
||||||
|
|
||||||
|
import types
|
||||||
|
|
||||||
|
const
|
||||||
|
separators = @["AND", "OR"]
|
||||||
|
validFilters* = @[
|
||||||
|
"media", "images", "twimg", "videos",
|
||||||
|
"native_video", "consumer_video", "pro_video",
|
||||||
|
"links", "news", "quote", "mentions",
|
||||||
|
"replies", "retweets", "nativeretweets",
|
||||||
|
"verified", "safe"
|
||||||
|
]
|
||||||
|
|
||||||
|
# Experimental, this might break in the future
|
||||||
|
# Till then, it results in shorter urls
|
||||||
|
const
|
||||||
|
posPrefix = "thGAVUV0VFVB"
|
||||||
|
posSuffix = "EjUAFQAlAFUAFQAA"
|
||||||
|
|
||||||
|
template `@`(param: string): untyped =
|
||||||
|
if param in pms: pms[param]
|
||||||
|
else: ""
|
||||||
|
|
||||||
|
proc initQuery*(pms: Table[string, string]; name=""): Query =
|
||||||
|
result = Query(
|
||||||
|
kind: parseEnum[QueryKind](@"kind", custom),
|
||||||
|
text: @"text",
|
||||||
|
filters: validFilters.filterIt("f-" & it in pms),
|
||||||
|
excludes: validFilters.filterIt("e-" & it in pms),
|
||||||
|
since: @"since",
|
||||||
|
until: @"until",
|
||||||
|
near: @"near"
|
||||||
|
)
|
||||||
|
|
||||||
|
if name.len > 0:
|
||||||
|
result.fromUser = name.split(",")
|
||||||
|
|
||||||
|
if @"e-nativeretweets".len == 0:
|
||||||
|
result.includes.add "nativeretweets"
|
||||||
|
|
||||||
|
proc getMediaQuery*(name: string): Query =
|
||||||
|
Query(
|
||||||
|
kind: media,
|
||||||
|
filters: @["twimg", "native_video"],
|
||||||
|
fromUser: @[name],
|
||||||
|
sep: "OR"
|
||||||
|
)
|
||||||
|
|
||||||
|
proc getReplyQuery*(name: string): Query =
|
||||||
|
Query(
|
||||||
|
kind: replies,
|
||||||
|
includes: @["nativeretweets"],
|
||||||
|
fromUser: @[name]
|
||||||
|
)
|
||||||
|
|
||||||
|
proc genQueryParam*(query: Query): string =
|
||||||
|
var filters: seq[string]
|
||||||
|
var param: string
|
||||||
|
|
||||||
|
if query.kind == users:
|
||||||
|
return query.text
|
||||||
|
|
||||||
|
for i, user in query.fromUser:
|
||||||
|
param &= &"from:{user} "
|
||||||
|
if i < query.fromUser.high:
|
||||||
|
param &= "OR "
|
||||||
|
|
||||||
|
for f in query.filters:
|
||||||
|
filters.add "filter:" & f
|
||||||
|
for e in query.excludes:
|
||||||
|
filters.add "-filter:" & e
|
||||||
|
for i in query.includes:
|
||||||
|
filters.add "include:" & i
|
||||||
|
|
||||||
|
result = strip(param & filters.join(&" {query.sep} "))
|
||||||
|
if query.since.len > 0:
|
||||||
|
result &= " since:" & query.since
|
||||||
|
if query.until.len > 0:
|
||||||
|
result &= " until:" & query.until
|
||||||
|
if query.near.len > 0:
|
||||||
|
result &= &" near:\"{query.near}\" within:15mi"
|
||||||
|
if query.text.len > 0:
|
||||||
|
result &= " " & query.text
|
||||||
|
|
||||||
|
proc genQueryUrl*(query: Query; onlyParam=false): string =
|
||||||
|
if query.fromUser.len > 0:
|
||||||
|
result = "/" & query.fromUser.join(",")
|
||||||
|
|
||||||
|
if query.fromUser.len > 1 and query.kind == posts:
|
||||||
|
return result & "?"
|
||||||
|
|
||||||
|
if query.kind notin {custom, users}:
|
||||||
|
return result & &"/{query.kind}?"
|
||||||
|
|
||||||
|
if onlyParam:
|
||||||
|
result = ""
|
||||||
|
else:
|
||||||
|
result &= &"/search?"
|
||||||
|
|
||||||
|
var params = @[&"kind={query.kind}"]
|
||||||
|
if query.text.len > 0:
|
||||||
|
params.add "text=" & encodeUrl(query.text)
|
||||||
|
for f in query.filters:
|
||||||
|
params.add "f-" & f & "=on"
|
||||||
|
for e in query.excludes:
|
||||||
|
params.add "e-" & e & "=on"
|
||||||
|
for i in query.includes:
|
||||||
|
params.add "i-" & i & "=on"
|
||||||
|
|
||||||
|
if query.since.len > 0:
|
||||||
|
params.add "since=" & query.since
|
||||||
|
if query.until.len > 0:
|
||||||
|
params.add "until=" & query.until
|
||||||
|
if query.near.len > 0:
|
||||||
|
params.add "near=" & query.near
|
||||||
|
|
||||||
|
if params.len > 0:
|
||||||
|
result &= params.join("&")
|
||||||
|
|
||||||
|
proc cleanPos*(pos: string): string =
|
||||||
|
pos.multiReplace((posPrefix, ""), (posSuffix, ""))
|
||||||
|
|
||||||
|
proc genPos*(pos: string): string =
|
||||||
|
result = posPrefix & pos
|
||||||
|
if "A==" notin result:
|
||||||
|
result &= posSuffix
|
|
@ -3,12 +3,12 @@ import asyncdispatch, strutils
|
||||||
import jester
|
import jester
|
||||||
|
|
||||||
import router_utils, timeline
|
import router_utils, timeline
|
||||||
import ".."/[cache, agents, search]
|
import ".."/[cache, agents, query]
|
||||||
import ../views/general
|
import ../views/general
|
||||||
|
|
||||||
include "../views/rss.nimf"
|
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)
|
let (profile, timeline, _) = await fetchSingleTimeline(name, "", getAgent(), query)
|
||||||
return renderTimelineRss(timeline.content, profile)
|
return renderTimelineRss(timeline.content, profile)
|
||||||
|
|
||||||
|
@ -21,12 +21,16 @@ proc createRssRouter*(cfg: Config) =
|
||||||
router rss:
|
router rss:
|
||||||
get "/@name/rss":
|
get "/@name/rss":
|
||||||
cond '.' notin @"name"
|
cond '.' notin @"name"
|
||||||
respRss(await showRss(@"name", none(Query)))
|
respRss(await showRss(@"name", Query()))
|
||||||
|
|
||||||
get "/@name/replies/rss":
|
get "/@name/replies/rss":
|
||||||
cond '.' notin @"name"
|
cond '.' notin @"name"
|
||||||
respRss(await showRss(@"name", some(getReplyQuery(@"name"))))
|
respRss(await showRss(@"name", getReplyQuery(@"name")))
|
||||||
|
|
||||||
get "/@name/media/rss":
|
get "/@name/media/rss":
|
||||||
cond '.' notin @"name"
|
cond '.' notin @"name"
|
||||||
respRss(await showRss(@"name", some(getMediaQuery(@"name"))))
|
respRss(await showRss(@"name", getMediaQuery(@"name")))
|
||||||
|
|
||||||
|
get "/@name/search/rss":
|
||||||
|
cond '.' notin @"name"
|
||||||
|
respRss(await showRss(@"name", initQuery(params(request), name=(@"name"))))
|
||||||
|
|
|
@ -0,0 +1,30 @@
|
||||||
|
import strutils, sequtils, uri
|
||||||
|
|
||||||
|
import jester
|
||||||
|
|
||||||
|
import router_utils
|
||||||
|
import ".."/[query, types, utils, api, agents, prefs]
|
||||||
|
import ../views/[general, search]
|
||||||
|
|
||||||
|
export search
|
||||||
|
|
||||||
|
proc createSearchRouter*(cfg: Config) =
|
||||||
|
router search:
|
||||||
|
get "/search":
|
||||||
|
if @"text".len > 200:
|
||||||
|
resp Http400, showError("Search input too long.", cfg.title)
|
||||||
|
|
||||||
|
let prefs = cookiePrefs()
|
||||||
|
let query = initQuery(params(request))
|
||||||
|
|
||||||
|
case query.kind
|
||||||
|
of users:
|
||||||
|
if "," in @"text":
|
||||||
|
redirect("/" & @"text")
|
||||||
|
let users = await getSearch[Profile](query, @"after", getAgent())
|
||||||
|
resp renderMain(renderUserSearch(users, prefs), prefs, cfg.title, path=getPath())
|
||||||
|
of custom:
|
||||||
|
let tweets = await getSearch[Tweet](query, @"after", getAgent())
|
||||||
|
resp renderMain(renderTweetSearch(tweets, prefs, getPath()), prefs, cfg.title, path=getPath())
|
||||||
|
else:
|
||||||
|
resp Http404, showError("Invalid search.", cfg.title)
|
|
@ -3,20 +3,20 @@ import asyncdispatch, strutils, sequtils, uri
|
||||||
import jester
|
import jester
|
||||||
|
|
||||||
import router_utils
|
import router_utils
|
||||||
import ".."/[api, prefs, types, utils, cache, formatters, agents, search]
|
import ".."/[api, prefs, types, utils, cache, formatters, agents, query]
|
||||||
import ../views/[general, profile, timeline, status]
|
import ../views/[general, profile, timeline, status, search]
|
||||||
|
|
||||||
include "../views/rss.nimf"
|
include "../views/rss.nimf"
|
||||||
|
|
||||||
export uri, sequtils
|
export uri, sequtils
|
||||||
export router_utils
|
export router_utils
|
||||||
export api, cache, formatters, search, agents
|
export api, cache, formatters, query, agents
|
||||||
export profile, timeline, status
|
export profile, timeline, status
|
||||||
|
|
||||||
type ProfileTimeline = (Profile, Timeline, seq[GalleryPhoto])
|
type ProfileTimeline = (Profile, Timeline, seq[GalleryPhoto])
|
||||||
|
|
||||||
proc fetchSingleTimeline*(name, after, agent: string;
|
proc fetchSingleTimeline*(name, after, agent: string;
|
||||||
query: Option[Query]): Future[ProfileTimeline] {.async.} =
|
query: Query): Future[ProfileTimeline] {.async.} =
|
||||||
let railFut = getPhotoRail(name, agent)
|
let railFut = getPhotoRail(name, agent)
|
||||||
|
|
||||||
var timeline: Timeline
|
var timeline: Timeline
|
||||||
|
@ -26,14 +26,14 @@ proc fetchSingleTimeline*(name, after, agent: string;
|
||||||
if cachedProfile.isSome:
|
if cachedProfile.isSome:
|
||||||
profile = get(cachedProfile)
|
profile = get(cachedProfile)
|
||||||
|
|
||||||
if query.isNone:
|
if query.kind == posts:
|
||||||
if cachedProfile.isSome:
|
if cachedProfile.isSome:
|
||||||
timeline = await getTimeline(name, after, agent)
|
timeline = await getTimeline(name, after, agent)
|
||||||
else:
|
else:
|
||||||
(profile, timeline) = await getProfileAndTimeline(name, agent, after)
|
(profile, timeline) = await getProfileAndTimeline(name, agent, after)
|
||||||
cache(profile)
|
cache(profile)
|
||||||
else:
|
else:
|
||||||
var timelineFut = getTimelineSearch(get(query), after, agent)
|
var timelineFut = getSearch[Tweet](query, after, agent)
|
||||||
if cachedProfile.isNone:
|
if cachedProfile.isNone:
|
||||||
profile = await getCachedProfile(name, agent)
|
profile = await getCachedProfile(name, agent)
|
||||||
timeline = await timelineFut
|
timeline = await timelineFut
|
||||||
|
@ -42,16 +42,14 @@ proc fetchSingleTimeline*(name, after, agent: string;
|
||||||
return (profile, timeline, await railFut)
|
return (profile, timeline, await railFut)
|
||||||
|
|
||||||
proc fetchMultiTimeline*(names: seq[string]; after, agent: string;
|
proc fetchMultiTimeline*(names: seq[string]; after, agent: string;
|
||||||
query: Option[Query]): Future[Timeline] {.async.} =
|
query: Query): Future[Timeline] {.async.} =
|
||||||
var q = query
|
var q = query
|
||||||
if q.isSome:
|
q.fromUser = names
|
||||||
get(q).fromUser = names
|
if q.kind == posts and "replies" notin q.excludes:
|
||||||
else:
|
q.excludes.add "replies"
|
||||||
q = some(Query(kind: multi, fromUser: names, excludes: @["replies"]))
|
return await getSearch[Tweet](q, after, agent)
|
||||||
|
|
||||||
return await getTimelineSearch(get(q), after, agent)
|
proc showTimeline*(name, after: string; query: Query;
|
||||||
|
|
||||||
proc showTimeline*(name, after: string; query: Option[Query];
|
|
||||||
prefs: Prefs; path, title, rss: string): Future[string] {.async.} =
|
prefs: Prefs; path, title, rss: string): Future[string] {.async.} =
|
||||||
let agent = getAgent()
|
let agent = getAgent()
|
||||||
let names = name.strip(chars={'/'}).split(",").filterIt(it.len > 0)
|
let names = name.strip(chars={'/'}).split(",").filterIt(it.len > 0)
|
||||||
|
@ -64,7 +62,7 @@ proc showTimeline*(name, after: string; query: Option[Query];
|
||||||
else:
|
else:
|
||||||
let
|
let
|
||||||
timeline = await fetchMultiTimeline(names, after, agent, query)
|
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")
|
return renderMain(html, prefs, title, "Multi")
|
||||||
|
|
||||||
template respTimeline*(timeline: typed) =
|
template respTimeline*(timeline: typed) =
|
||||||
|
@ -79,27 +77,28 @@ proc createTimelineRouter*(cfg: Config) =
|
||||||
get "/@name/?":
|
get "/@name/?":
|
||||||
cond '.' notin @"name"
|
cond '.' notin @"name"
|
||||||
let rss = "/$1/rss" % @"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))
|
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":
|
get "/@name/replies":
|
||||||
cond '.' notin @"name"
|
cond '.' notin @"name"
|
||||||
let rss = "/$1/replies/rss" % @"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))
|
cookiePrefs(), getPath(), cfg.title, rss))
|
||||||
|
|
||||||
get "/@name/media":
|
get "/@name/media":
|
||||||
cond '.' notin @"name"
|
cond '.' notin @"name"
|
||||||
let rss = "/$1/media/rss" % @"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))
|
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":
|
get "/@name/status/@id":
|
||||||
cond '.' notin @"name"
|
cond '.' notin @"name"
|
||||||
let prefs = cookiePrefs()
|
let prefs = cookiePrefs()
|
||||||
|
|
|
@ -10,7 +10,7 @@
|
||||||
@include center-panel($error_red);
|
@include center-panel($error_red);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-panel > form {
|
.search-bar > form {
|
||||||
@include center-panel($darkest-grey);
|
@include center-panel($darkest-grey);
|
||||||
|
|
||||||
button {
|
button {
|
||||||
|
|
|
@ -58,3 +58,40 @@
|
||||||
border-color: $accent_light;
|
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 'navbar';
|
||||||
@import 'inputs';
|
@import 'inputs';
|
||||||
@import 'timeline';
|
@import 'timeline';
|
||||||
|
@import 'search';
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: $bg_color;
|
background-color: $bg_color;
|
||||||
|
@ -68,9 +69,6 @@ ul.about-list {
|
||||||
.container {
|
.container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
|
||||||
|
|
||||||
#content {
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding-top: 50px;
|
padding-top: 50px;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
|
|
|
@ -12,7 +12,8 @@ button {
|
||||||
float: right;
|
float: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="text"] {
|
input[type="text"],
|
||||||
|
input[type="date"] {
|
||||||
@include input-colors;
|
@include input-colors;
|
||||||
background-color: $bg_elements;
|
background-color: $bg_elements;
|
||||||
padding: 1px 4px;
|
padding: 1px 4px;
|
||||||
|
@ -22,9 +23,50 @@ input[type="text"] {
|
||||||
font-size: 14px;
|
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 {
|
.icon-button button {
|
||||||
color: $accent;
|
color: $accent;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
float: none;
|
float: none;
|
||||||
padding: unset;
|
padding: unset;
|
||||||
|
@ -88,6 +130,10 @@ input[type="text"] {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pref-group {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
.preferences {
|
.preferences {
|
||||||
button {
|
button {
|
||||||
margin: 6px 0 3px 0;
|
margin: 6px 0 3px 0;
|
||||||
|
@ -103,6 +149,10 @@ input[type="text"] {
|
||||||
max-width: 120px;
|
max-width: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pref-group {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.pref-input {
|
.pref-input {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
|
|
|
@ -1,19 +1,18 @@
|
||||||
@import '_variables';
|
@import '_variables';
|
||||||
|
|
||||||
nav {
|
nav {
|
||||||
z-index: 1000;
|
display: flex;
|
||||||
background-color: $bg_overlays;
|
|
||||||
box-shadow: 0 0 4px $shadow;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-bar {
|
|
||||||
padding: 0;
|
|
||||||
width: 100%;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
background-color: $bg_overlays;
|
||||||
|
box-shadow: 0 0 4px $shadow;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
height: 50px;
|
height: 50px;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
.inner-nav {
|
.inner-nav {
|
||||||
margin: auto;
|
margin: auto;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
|
@ -21,7 +20,6 @@ nav {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-basis: 920px;
|
flex-basis: 920px;
|
||||||
height: 50px;
|
height: 50px;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-name {
|
.site-name {
|
||||||
|
@ -39,7 +37,7 @@ nav {
|
||||||
height: 35px;
|
height: 35px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item {
|
.nav-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
line-height: 50px;
|
line-height: 50px;
|
||||||
|
|
|
@ -4,13 +4,13 @@
|
||||||
@import 'card';
|
@import 'card';
|
||||||
@import 'photo-rail';
|
@import 'photo-rail';
|
||||||
|
|
||||||
.profile-timeline, .profile-tabs {
|
|
||||||
@include panel(auto, 900px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-tabs {
|
.profile-tabs {
|
||||||
> .timeline-tab {
|
@include panel(auto, 900px);
|
||||||
|
|
||||||
|
.timeline-container {
|
||||||
|
float: right;
|
||||||
width: 68% !important;
|
width: 68% !important;
|
||||||
|
max-width: unset;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -43,11 +43,19 @@
|
||||||
top: 50px;
|
top: 50px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-result .username {
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-result .tweet-header {
|
||||||
|
margin-bottom: unset;
|
||||||
|
}
|
||||||
|
|
||||||
@media(max-width: 600px) {
|
@media(max-width: 600px) {
|
||||||
.profile-tabs {
|
.profile-tabs {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
|
|
||||||
.timeline-tab {
|
.timeline-container {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,13 +16,9 @@
|
||||||
&-header-mobile {
|
&-header-mobile {
|
||||||
padding: 5px 12px 0;
|
padding: 5px 12px 0;
|
||||||
display: none;
|
display: none;
|
||||||
}
|
width: calc(100% - 24px);
|
||||||
|
|
||||||
&-label {
|
|
||||||
width: 100%;
|
|
||||||
float: unset;
|
float: unset;
|
||||||
color: $accent;
|
color: $accent;
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -57,13 +53,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#photo-rail-toggle {
|
@include create-toggle(photo-rail-grid, 640px);
|
||||||
display: none;
|
#photo-rail-grid-toggle:checked ~ .photo-rail-grid {
|
||||||
|
|
||||||
&:checked ~ .photo-rail-grid {
|
|
||||||
max-height: 600px;
|
|
||||||
padding-bottom: 12px;
|
padding-bottom: 12px;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media(max-width: 600px) {
|
@media(max-width: 600px) {
|
||||||
|
@ -72,7 +64,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.photo-rail-header-mobile {
|
.photo-rail-header-mobile {
|
||||||
display: block;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.photo-rail-grid {
|
.photo-rail-grid {
|
||||||
|
@ -82,3 +74,23 @@
|
||||||
transition: max-height 0.4s;
|
transition: max-height 0.4s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media(max-width: 600px) {
|
||||||
|
.photo-rail-grid {
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
#photo-rail-grid-toggle:checked ~ .photo-rail-grid {
|
||||||
|
max-height: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media(max-width: 450px) {
|
||||||
|
.photo-rail-grid {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
#photo-rail-grid-toggle:checked ~ .photo-rail-grid {
|
||||||
|
max-height: 450px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,120 @@
|
||||||
|
@import '_variables';
|
||||||
|
@import '_mixins';
|
||||||
|
|
||||||
|
.search-title {
|
||||||
|
font-weight: bold;
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-field {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
button {
|
||||||
|
margin: 0 2px 0 0;
|
||||||
|
height: 23px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pref-input {
|
||||||
|
margin: 0 4px 0 0;
|
||||||
|
flex-grow: 1;
|
||||||
|
height: 23px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"] {
|
||||||
|
height: calc(100% - 4px);
|
||||||
|
width: calc(100% - 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
> label {
|
||||||
|
display: inline;
|
||||||
|
background-color: #121212;
|
||||||
|
color: #F8F8F2;
|
||||||
|
border: 1px solid #FF6C6091;
|
||||||
|
padding: 1px 6px 2px 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
|
||||||
|
@include input-colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include create-toggle(search-panel, 200px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-panel {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 0.4s;
|
||||||
|
|
||||||
|
flex-grow: 1;
|
||||||
|
font-weight: initial;
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
line-height: 1.7em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-container {
|
||||||
|
display: inline;
|
||||||
|
padding-right: unset;
|
||||||
|
margin-bottom: unset;
|
||||||
|
margin-left: 23px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
right: unset;
|
||||||
|
left: -22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-container .checkbox:after {
|
||||||
|
top: -4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
line-height: unset;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
height: 21px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pref-input {
|
||||||
|
display: block;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
|
||||||
|
input {
|
||||||
|
height: 21px;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-toggles {
|
||||||
|
flex-grow: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, auto);
|
||||||
|
grid-column-gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-tabs {
|
||||||
|
@include search-resize(820px, 5);
|
||||||
|
@include search-resize(725px, 4);
|
||||||
|
@include search-resize(600px, 6);
|
||||||
|
@include search-resize(560px, 5);
|
||||||
|
@include search-resize(480px, 4);
|
||||||
|
@include search-resize(410px, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@include search-resize(560px, 5);
|
||||||
|
@include search-resize(480px, 4);
|
||||||
|
@include search-resize(410px, 3);
|
|
@ -1,36 +1,28 @@
|
||||||
@import '_variables';
|
@import '_variables';
|
||||||
|
|
||||||
#posts {
|
.timeline-container {
|
||||||
|
@include panel(100%, 600px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline {
|
||||||
background-color: $bg_panel;
|
background-color: $bg_panel;
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-tab {
|
> div:not(:last-child) {
|
||||||
float: right;
|
border-bottom: 1px solid $border_grey;
|
||||||
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%;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.multi-header {
|
.timeline-header {
|
||||||
background-color: $bg_panel;
|
background-color: $bg_panel;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 10px;
|
padding: 8px;
|
||||||
display: block;
|
display: block;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
|
|
||||||
|
button {
|
||||||
|
float: unset;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab {
|
.tab {
|
||||||
|
@ -72,20 +64,11 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-tweet {
|
|
||||||
border-bottom: 1px solid $border_grey;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-footer {
|
.timeline-footer {
|
||||||
background-color: $bg_panel;
|
background-color: $bg_panel;
|
||||||
padding: 6px 0;
|
padding: 6px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-header {
|
|
||||||
background-color: $bg_panel;
|
|
||||||
padding: 6px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-protected {
|
.timeline-protected {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
|
@ -119,11 +102,7 @@
|
||||||
background-color: $bg_panel;
|
background-color: $bg_panel;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: .75em 0;
|
padding: .75em 0;
|
||||||
display: block;
|
display: block !important;
|
||||||
|
|
||||||
&.status-el {
|
|
||||||
border-bottom: 1px solid $border_grey;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
a {
|
||||||
background-color: $darkest_grey;
|
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 'poll';
|
||||||
@import 'quote';
|
@import 'quote';
|
||||||
|
|
||||||
.status-el {
|
.tweet-body {
|
||||||
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 {
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
margin-left: 58px;
|
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 {
|
.tweet-header {
|
||||||
|
@ -36,6 +32,7 @@
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
pointer-events: all;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -79,7 +76,6 @@
|
||||||
float: left;
|
float: left;
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
margin-left: -58px;
|
margin-left: -58px;
|
||||||
position: absolute;
|
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
@ -89,6 +85,7 @@
|
||||||
.replying-to {
|
.replying-to {
|
||||||
color: $fg_dark;
|
color: $fg_dark;
|
||||||
margin: -2px 0 4px;
|
margin: -2px 0 4px;
|
||||||
|
pointer-events: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.retweet, .pinned, .tweet-stats {
|
.retweet, .pinned, .tweet-stats {
|
||||||
|
@ -121,6 +118,7 @@
|
||||||
|
|
||||||
.show-thread {
|
.show-thread {
|
||||||
display: block;
|
display: block;
|
||||||
|
pointer-events: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.unavailable-box {
|
.unavailable-box {
|
||||||
|
@ -131,3 +129,15 @@
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background-color: $bg_color;
|
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 {
|
.card {
|
||||||
margin: 5px 0;
|
margin: 5px 0;
|
||||||
|
pointer-events: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-container {
|
.card-container {
|
||||||
|
@ -31,6 +32,7 @@
|
||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
@include ellipsis;
|
@include ellipsis;
|
||||||
|
white-space: unset;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 1.15em;
|
font-size: 1.15em;
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,9 +9,11 @@
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
max-height: 379.5px;
|
max-height: 379.5px;
|
||||||
max-width: 533px;
|
max-width: 533px;
|
||||||
|
pointer-events: all;
|
||||||
|
|
||||||
.still-image {
|
.still-image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -26,6 +28,7 @@
|
||||||
flex-flow: column;
|
flex-flow: column;
|
||||||
background-color: $bg_color;
|
background-color: $bg_color;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
pointer-events: all;
|
||||||
|
|
||||||
.image-attachment {
|
.image-attachment {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
@ -66,7 +69,14 @@
|
||||||
|
|
||||||
.single-image {
|
.single-image {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 600px;
|
||||||
|
|
||||||
|
.attachments {
|
||||||
width: unset;
|
width: unset;
|
||||||
|
max-height: unset;
|
||||||
|
display: inherit;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay-circle {
|
.overlay-circle {
|
||||||
|
|
|
@ -24,14 +24,17 @@
|
||||||
margin-right: 6px;
|
margin-right: 6px;
|
||||||
min-width: 30px;
|
min-width: 30px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
pointer-events: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.poll-choice-option {
|
.poll-choice-option {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
pointer-events: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.poll-info {
|
.poll-info {
|
||||||
color: $grey;
|
color: $grey;
|
||||||
|
pointer-events: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leader .poll-choice-bar {
|
.leader .poll-choice-bar {
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
pointer-events: all;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: $grey;
|
border-color: $grey;
|
||||||
|
@ -30,6 +31,10 @@
|
||||||
position: absolute;
|
position: absolute;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quote .quote-link {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.quote-text {
|
.quote-text {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
|
|
@ -3,7 +3,6 @@
|
||||||
|
|
||||||
.conversation {
|
.conversation {
|
||||||
@include panel(100%, 600px);
|
@include panel(100%, 600px);
|
||||||
background-color: $bg_color !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-thread {
|
.main-thread {
|
||||||
|
@ -11,7 +10,7 @@
|
||||||
background-color: $bg_panel;
|
background-color: $bg_panel;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-tweet .status-content {
|
.main-tweet .tweet-content {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -21,7 +20,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.thread-line {
|
.thread-line {
|
||||||
.status-el::before {
|
.timeline-item::before,
|
||||||
|
&.timeline-item::before {
|
||||||
background: $accent_dark;
|
background: $accent_dark;
|
||||||
content: '';
|
content: '';
|
||||||
position: relative;
|
position: relative;
|
||||||
|
@ -32,6 +32,8 @@
|
||||||
margin-left: -3px;
|
margin-left: -3px;
|
||||||
margin-bottom: 37px;
|
margin-bottom: 37px;
|
||||||
top: 56px;
|
top: 56px;
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.unavailable::before {
|
.unavailable::before {
|
||||||
|
@ -54,7 +56,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.thread-last .status-el::before {
|
.timeline-item.thread-last::before {
|
||||||
background: unset;
|
background: unset;
|
||||||
min-width: unset;
|
min-width: unset;
|
||||||
width: 0;
|
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
|
type
|
||||||
QueryKind* = enum
|
QueryKind* = enum
|
||||||
replies, media, multi, custom = "search"
|
posts, replies, media, users, custom
|
||||||
|
|
||||||
Query* = object
|
Query* = object
|
||||||
kind*: QueryKind
|
kind*: QueryKind
|
||||||
|
text*: string
|
||||||
filters*: seq[string]
|
filters*: seq[string]
|
||||||
includes*: seq[string]
|
includes*: seq[string]
|
||||||
excludes*: seq[string]
|
excludes*: seq[string]
|
||||||
fromUser*: seq[string]
|
fromUser*: seq[string]
|
||||||
|
since*: string
|
||||||
|
until*: string
|
||||||
|
near*: string
|
||||||
sep*: string
|
sep*: string
|
||||||
|
|
||||||
Result*[T] = ref object
|
Result*[T] = ref object
|
||||||
|
@ -73,7 +77,7 @@ type
|
||||||
maxId*: string
|
maxId*: string
|
||||||
hasMore*: bool
|
hasMore*: bool
|
||||||
beginning*: bool
|
beginning*: bool
|
||||||
query*: Option[Query]
|
query*: Query
|
||||||
|
|
||||||
Gif* = object
|
Gif* = object
|
||||||
url*: string
|
url*: string
|
||||||
|
|
|
@ -42,7 +42,7 @@ proc cleanFilename*(filename: string): string =
|
||||||
|
|
||||||
proc filterParams*(params: Table): seq[(string, string)] =
|
proc filterParams*(params: Table): seq[(string, string)] =
|
||||||
let filter = ["name", "id"]
|
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 =
|
proc isTwitterUrl*(url: string): bool =
|
||||||
parseUri(url).hostname in twitterDomains
|
parseUri(url).hostname in twitterDomains
|
||||||
|
|
|
@ -6,14 +6,15 @@ import ../utils, ../types
|
||||||
const doctype = "<!DOCTYPE html>\n"
|
const doctype = "<!DOCTYPE html>\n"
|
||||||
|
|
||||||
proc renderNavbar*(title, path, rss: string): VNode =
|
proc renderNavbar*(title, path, rss: string): VNode =
|
||||||
buildHtml(nav(id="nav", class="nav-bar container")):
|
buildHtml(nav):
|
||||||
tdiv(class="inner-nav"):
|
tdiv(class="inner-nav"):
|
||||||
tdiv(class="item"):
|
tdiv(class="nav-item"):
|
||||||
a(class="site-name", href="/"): text title
|
a(class="site-name", href="/"): text title
|
||||||
|
|
||||||
a(href="/"): img(class="site-logo", src="/logo.png")
|
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:
|
if rss.len > 0:
|
||||||
icon "rss", title="RSS Feed", href=rss
|
icon "rss", title="RSS Feed", href=rss
|
||||||
icon "info-circled", title="About", href="/about"
|
icon "info-circled", title="About", href="/about"
|
||||||
|
@ -55,18 +56,11 @@ proc renderMain*(body: VNode; prefs: Prefs; title="Nitter"; titleText=""; desc="
|
||||||
body:
|
body:
|
||||||
renderNavbar(title, path, rss)
|
renderNavbar(title, path, rss)
|
||||||
|
|
||||||
tdiv(id="content", class="container"):
|
tdiv(class="container"):
|
||||||
body
|
body
|
||||||
|
|
||||||
result = doctype & $node
|
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 =
|
proc renderError*(error: string): VNode =
|
||||||
buildHtml(tdiv(class="panel-container")):
|
buildHtml(tdiv(class="panel-container")):
|
||||||
tdiv(class="error-panel"):
|
tdiv(class="error-panel"):
|
||||||
|
|
|
@ -1,34 +1,9 @@
|
||||||
import tables, macros, strformat, strutils, xmltree
|
import tables, macros, strutils
|
||||||
import karax/[karaxdsl, vdom, vstyles]
|
import karax/[karaxdsl, vdom, vstyles]
|
||||||
|
|
||||||
import renderutils
|
import renderutils
|
||||||
import ../types, ../prefs_impl
|
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 =
|
macro renderPrefs*(): untyped =
|
||||||
result = nnkCall.newTree(
|
result = nnkCall.newTree(
|
||||||
ident("buildHtml"), ident("tdiv"), nnkStmtList.newTree())
|
ident("buildHtml"), ident("tdiv"), nnkStmtList.newTree())
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import strutils, strformat
|
import strutils, strformat
|
||||||
import karax/[karaxdsl, vdom, vstyles]
|
import karax/[karaxdsl, vdom, vstyles]
|
||||||
|
|
||||||
import tweet, timeline, renderutils
|
import renderutils, search
|
||||||
import ".."/[types, utils, formatters]
|
import ".."/[types, utils, formatters]
|
||||||
|
|
||||||
proc renderStat(num, class: string; text=""): VNode =
|
proc renderStat(num, class: string; text=""): VNode =
|
||||||
|
@ -54,9 +54,8 @@ proc renderPhotoRail(profile: Profile; photoRail: seq[GalleryPhoto]): VNode =
|
||||||
a(href=(&"/{profile.username}/media")):
|
a(href=(&"/{profile.username}/media")):
|
||||||
icon "picture", $profile.media & " Photos and videos"
|
icon "picture", $profile.media & " Photos and videos"
|
||||||
|
|
||||||
input(id="photo-rail-toggle", `type`="checkbox")
|
input(id="photo-rail-grid-toggle", `type`="checkbox")
|
||||||
tdiv(class="photo-rail-header-mobile"):
|
label(`for`="photo-rail-grid-toggle", class="photo-rail-header-mobile"):
|
||||||
label(`for`="photo-rail-toggle", class="photo-rail-label"):
|
|
||||||
icon "picture", $profile.media & " Photos and videos"
|
icon "picture", $profile.media & " Photos and videos"
|
||||||
icon "down"
|
icon "down"
|
||||||
|
|
||||||
|
@ -75,8 +74,15 @@ proc renderBanner(profile: Profile): VNode =
|
||||||
a(href=getPicUrl(profile.banner), target="_blank"):
|
a(href=getPicUrl(profile.banner), target="_blank"):
|
||||||
genImg(profile.banner)
|
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;
|
proc renderProfile*(profile: Profile; timeline: Timeline;
|
||||||
photoRail: seq[GalleryPhoto]; prefs: Prefs; path: string): VNode =
|
photoRail: seq[GalleryPhoto]; prefs: Prefs; path: string): VNode =
|
||||||
|
timeline.query.fromUser = @[profile.username]
|
||||||
buildHtml(tdiv(class="profile-tabs")):
|
buildHtml(tdiv(class="profile-tabs")):
|
||||||
if not prefs.hideBanner:
|
if not prefs.hideBanner:
|
||||||
tdiv(class="profile-banner"):
|
tdiv(class="profile-banner"):
|
||||||
|
@ -88,11 +94,7 @@ proc renderProfile*(profile: Profile; timeline: Timeline;
|
||||||
if photoRail.len > 0:
|
if photoRail.len > 0:
|
||||||
renderPhotoRail(profile, photoRail)
|
renderPhotoRail(profile, photoRail)
|
||||||
|
|
||||||
tdiv(class="timeline-tab"):
|
if profile.protected:
|
||||||
renderTimeline(timeline, profile.username, profile.protected, prefs, path)
|
renderProtected(profile.username)
|
||||||
|
else:
|
||||||
proc renderMulti*(timeline: Timeline; usernames: string;
|
renderTweetSearch(timeline, prefs, path)
|
||||||
prefs: Prefs; path: string): VNode =
|
|
||||||
buildHtml(tdiv(class="multi-timeline")):
|
|
||||||
tdiv(class="timeline-tab"):
|
|
||||||
renderTimeline(timeline, usernames, false, prefs, path, multi=true)
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import strutils
|
import strutils, strformat, xmltree
|
||||||
import karax/[karaxdsl, vdom]
|
import karax/[karaxdsl, vdom]
|
||||||
|
|
||||||
import ../types, ../utils
|
import ../types, ../utils
|
||||||
|
@ -39,9 +39,12 @@ proc linkText*(text: string; class=""): VNode =
|
||||||
buildHtml():
|
buildHtml():
|
||||||
a(href=url, class=class): text text
|
a(href=url, class=class): text text
|
||||||
|
|
||||||
proc refererField*(path: string): VNode =
|
proc hiddenField*(name, value: string): VNode =
|
||||||
buildHtml():
|
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 =
|
proc iconReferer*(icon, action, path: string, title=""): VNode =
|
||||||
buildHtml(form(`method`="get", action=action, class="icon-button")):
|
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
|
refererField path
|
||||||
button(`type`="submit"):
|
button(`type`="submit"):
|
||||||
text text
|
text text
|
||||||
|
|
||||||
|
proc genCheckbox*(pref, label: string; state: bool): VNode =
|
||||||
|
buildHtml(label(class="pref-group checkbox-container")):
|
||||||
|
text label
|
||||||
|
if state: input(name=pref, `type`="checkbox", checked="")
|
||||||
|
else: input(name=pref, `type`="checkbox")
|
||||||
|
span(class="checkbox")
|
||||||
|
|
||||||
|
proc genInput*(pref, label, state, placeholder: string; class=""; autofocus=false): VNode =
|
||||||
|
let s = xmltree.escape(state)
|
||||||
|
let p = xmltree.escape(placeholder)
|
||||||
|
let a = if autofocus: "autofocus" else: ""
|
||||||
|
buildHtml(tdiv(class=("pref-group pref-input " & class))):
|
||||||
|
if label.len > 0:
|
||||||
|
label(`for`=pref): text label
|
||||||
|
verbatim &"<input name={pref} type=\"text\" placeholder=\"{p}\" value=\"{s}\" {a}/>"
|
||||||
|
|
||||||
|
proc genSelect*(pref, label, state: string; options: seq[string]): VNode =
|
||||||
|
buildHtml(tdiv(class="pref-group")):
|
||||||
|
label(`for`=pref): text label
|
||||||
|
select(name=pref):
|
||||||
|
for opt in options:
|
||||||
|
if opt == state:
|
||||||
|
option(value=opt, selected=""): text opt
|
||||||
|
else:
|
||||||
|
option(value=opt): text opt
|
||||||
|
|
||||||
|
proc genDate*(pref, state: string): VNode =
|
||||||
|
buildHtml(span(class="date-input")):
|
||||||
|
if state.len > 0:
|
||||||
|
verbatim &"<input name={pref} type=\"date\" value=\"{state}\"/>"
|
||||||
|
else:
|
||||||
|
verbatim &"<input name={pref} type=\"date\"/>"
|
||||||
|
icon "calendar"
|
||||||
|
|
|
@ -0,0 +1,123 @@
|
||||||
|
import strutils, strformat, sequtils, unicode, tables
|
||||||
|
import karax/[karaxdsl, vdom, vstyles]
|
||||||
|
|
||||||
|
import renderutils, timeline
|
||||||
|
import ".."/[types, formatters, query]
|
||||||
|
|
||||||
|
let toggles = {
|
||||||
|
"nativeretweets": "Retweets",
|
||||||
|
"media": "Media",
|
||||||
|
"videos": "Videos",
|
||||||
|
"news": "News",
|
||||||
|
"verified": "Verified",
|
||||||
|
"native_video": "Native videos",
|
||||||
|
"replies": "Replies",
|
||||||
|
"links": "Links",
|
||||||
|
"images": "Images",
|
||||||
|
"safe": "Safe",
|
||||||
|
"quote": "Quotes",
|
||||||
|
"pro_video": "Pro videos"
|
||||||
|
}.toOrderedTable
|
||||||
|
|
||||||
|
proc renderSearch*(): VNode =
|
||||||
|
buildHtml(tdiv(class="panel-container")):
|
||||||
|
tdiv(class="search-bar"):
|
||||||
|
form(`method`="get", action="/search"):
|
||||||
|
hiddenField("kind", "users")
|
||||||
|
input(`type`="text", name="text", autofocus="", placeholder="Enter username...")
|
||||||
|
button(`type`="submit"): icon "search"
|
||||||
|
|
||||||
|
proc getTabClass(query: Query; tab: QueryKind): string =
|
||||||
|
var classes = @["tab-item"]
|
||||||
|
if query.kind == tab:
|
||||||
|
classes.add "active"
|
||||||
|
return classes.join(" ")
|
||||||
|
|
||||||
|
proc renderProfileTabs*(query: Query; username: string): VNode =
|
||||||
|
let link = "/" & username
|
||||||
|
buildHtml(ul(class="tab")):
|
||||||
|
li(class=query.getTabClass(posts)):
|
||||||
|
a(href=link): text "Tweets"
|
||||||
|
li(class=query.getTabClass(replies)):
|
||||||
|
a(href=(link & "/replies")): text "Tweets & Replies"
|
||||||
|
li(class=query.getTabClass(media)):
|
||||||
|
a(href=(link & "/media")): text "Media"
|
||||||
|
li(class=query.getTabClass(custom)):
|
||||||
|
a(href=(link & "/search")): text "Search"
|
||||||
|
|
||||||
|
proc renderSearchTabs*(query: Query): VNode =
|
||||||
|
var q = query
|
||||||
|
buildHtml(ul(class="tab")):
|
||||||
|
li(class=query.getTabClass(custom)):
|
||||||
|
q.kind = custom
|
||||||
|
a(href=genQueryUrl(q)): text "Tweets"
|
||||||
|
li(class=query.getTabClass(users)):
|
||||||
|
q.kind = users
|
||||||
|
a(href=genQueryUrl(q)): text "Users"
|
||||||
|
|
||||||
|
proc isPanelOpen(q: Query): bool =
|
||||||
|
q.fromUser.len == 0 and (q.filters.len > 0 or q.excludes.len > 0 or
|
||||||
|
@[q.near, q.until, q.since].anyIt(it.len > 0))
|
||||||
|
|
||||||
|
proc renderSearchPanel*(query: Query): VNode =
|
||||||
|
let user = query.fromUser.join(",")
|
||||||
|
let action = if user.len > 0: &"/{user}/search" else: "/search"
|
||||||
|
buildHtml(form(`method`="get", action=action, class="search-field")):
|
||||||
|
hiddenField("kind", "custom")
|
||||||
|
genInput("text", "", query.text, "Enter search...",
|
||||||
|
class="pref-inline", autofocus=true)
|
||||||
|
button(`type`="submit"): icon "search"
|
||||||
|
if isPanelOpen(query):
|
||||||
|
input(id="search-panel-toggle", `type`="checkbox", checked="")
|
||||||
|
else:
|
||||||
|
input(id="search-panel-toggle", `type`="checkbox")
|
||||||
|
label(`for`="search-panel-toggle"):
|
||||||
|
icon "down"
|
||||||
|
tdiv(class="search-panel"):
|
||||||
|
for f in @["filter", "exclude"]:
|
||||||
|
span(class="search-title"): text capitalize(f)
|
||||||
|
tdiv(class="search-toggles"):
|
||||||
|
for k, v in toggles:
|
||||||
|
let state =
|
||||||
|
if f == "filter": k in query.filters
|
||||||
|
else: k in query.excludes
|
||||||
|
genCheckbox(&"{f[0]}-{k}", v, state)
|
||||||
|
|
||||||
|
tdiv(class="search-row"):
|
||||||
|
tdiv:
|
||||||
|
span(class="search-title"): text "Time range"
|
||||||
|
tdiv(class="date-range"):
|
||||||
|
genDate("since", query.since)
|
||||||
|
span(class="search-title"): text "-"
|
||||||
|
genDate("until", query.until)
|
||||||
|
tdiv:
|
||||||
|
span(class="search-title"): text "Near"
|
||||||
|
genInput("near", "", query.near, placeholder="Location...")
|
||||||
|
|
||||||
|
proc renderTweetSearch*(tweets: Result[Tweet]; prefs: Prefs; path: string): VNode =
|
||||||
|
let query = tweets.query
|
||||||
|
buildHtml(tdiv(class="timeline-container")):
|
||||||
|
if query.fromUser.len > 1:
|
||||||
|
tdiv(class="timeline-header"):
|
||||||
|
text query.fromUser.join(" | ")
|
||||||
|
if query.fromUser.len == 0 or query.kind == custom:
|
||||||
|
tdiv(class="timeline-header"):
|
||||||
|
renderSearchPanel(query)
|
||||||
|
|
||||||
|
if query.fromUser.len > 0:
|
||||||
|
renderProfileTabs(query, query.fromUser.join(","))
|
||||||
|
else:
|
||||||
|
renderSearchTabs(query)
|
||||||
|
|
||||||
|
renderTimelineTweets(tweets, prefs, path)
|
||||||
|
|
||||||
|
proc renderUserSearch*(users: Result[Profile]; prefs: Prefs): VNode =
|
||||||
|
buildHtml(tdiv(class="timeline-container")):
|
||||||
|
tdiv(class="timeline-header"):
|
||||||
|
form(`method`="get", action="/search", class="search-field"):
|
||||||
|
hiddenField("kind", "users")
|
||||||
|
genInput("text", "", users.query.text, "Enter username...", class="pref-inline")
|
||||||
|
button(`type`="submit"): icon "search"
|
||||||
|
|
||||||
|
renderSearchTabs(users.query)
|
||||||
|
renderTimelineUsers(users, prefs)
|
|
@ -6,7 +6,7 @@ import tweet
|
||||||
proc renderMoreReplies(thread: Thread): VNode =
|
proc renderMoreReplies(thread: Thread): VNode =
|
||||||
let num = if thread.more != -1: $thread.more & " " else: ""
|
let num = if thread.more != -1: $thread.more & " " else: ""
|
||||||
let reply = if thread.more == 1: "reply" else: "replies"
|
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"):
|
a(class="more-replies-text", title="Not implemented yet"):
|
||||||
text $num & "more " & reply
|
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 =
|
proc renderConversation*(conversation: Conversation; prefs: Prefs; path: string): VNode =
|
||||||
let hasAfter = conversation.after != nil
|
let hasAfter = conversation.after != nil
|
||||||
buildHtml(tdiv(class="conversation", id="posts")):
|
buildHtml(tdiv(class="conversation")):
|
||||||
tdiv(class="main-thread"):
|
tdiv(class="main-thread"):
|
||||||
if conversation.before != nil:
|
if conversation.before != nil:
|
||||||
tdiv(class="before-tweet thread-line"):
|
tdiv(class="before-tweet thread-line"):
|
||||||
|
|
|
@ -1,99 +1,99 @@
|
||||||
import strutils, strformat, sequtils, algorithm, times
|
import strutils, strformat, sequtils, algorithm, times
|
||||||
import karax/[karaxdsl, vdom, vstyles]
|
import karax/[karaxdsl, vdom, vstyles]
|
||||||
|
|
||||||
import ../types, ../search
|
import ".."/[types, query, formatters]
|
||||||
import tweet, renderutils
|
import tweet, renderutils
|
||||||
|
|
||||||
proc getQuery(timeline: Timeline): string =
|
proc getQuery(query: Query): string =
|
||||||
if timeline.query.isNone: "?"
|
if query.kind == posts:
|
||||||
else: genQueryUrl(get(timeline.query))
|
result = "?"
|
||||||
|
else:
|
||||||
|
result = genQueryUrl(query)
|
||||||
|
if result[^1] != '?':
|
||||||
|
result &= "&"
|
||||||
|
|
||||||
proc getTabClass(timeline: Timeline; tab: string): string =
|
proc renderNewer(query: Query): VNode =
|
||||||
var classes = @["tab-item"]
|
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:
|
proc renderOlder(query: Query; minId: string): VNode =
|
||||||
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 =
|
|
||||||
buildHtml(tdiv(class="show-more")):
|
buildHtml(tdiv(class="show-more")):
|
||||||
a(href=(&"/{username}{getQuery(timeline)}after={timeline.minId}")):
|
a(href=(&"{getQuery(query)}after={minId}")):
|
||||||
text "Load older tweets"
|
text "Load older"
|
||||||
|
|
||||||
proc renderNoMore(): VNode =
|
proc renderNoMore(): VNode =
|
||||||
buildHtml(tdiv(class="timeline-footer")):
|
buildHtml(tdiv(class="timeline-footer")):
|
||||||
h2(class="timeline-end"):
|
h2(class="timeline-end"):
|
||||||
text "No more tweets."
|
text "No more items"
|
||||||
|
|
||||||
proc renderNoneFound(): VNode =
|
proc renderNoneFound(): VNode =
|
||||||
buildHtml(tdiv(class="timeline-header")):
|
buildHtml(tdiv(class="timeline-header")):
|
||||||
h2(class="timeline-none"):
|
h2(class="timeline-none"):
|
||||||
text "No tweets found."
|
text "No items 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."
|
|
||||||
|
|
||||||
proc renderThread(thread: seq[Tweet]; prefs: Prefs; path: string): VNode =
|
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):
|
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",
|
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 =
|
proc threadFilter(it: Tweet; tweetThread: string): bool =
|
||||||
it.retweet.isNone and it.reply.len == 0 and it.threadId == tweetThread
|
it.retweet.isNone and it.reply.len == 0 and it.threadId == tweetThread
|
||||||
|
|
||||||
proc renderTweets(timeline: Timeline; prefs: Prefs; path: string): VNode =
|
proc renderUser(user: Profile; prefs: Prefs): VNode =
|
||||||
buildHtml(tdiv(id="posts")):
|
buildHtml(tdiv(class="timeline-item")):
|
||||||
|
a(class="tweet-link", href=("/" & user.username))
|
||||||
|
tdiv(class="tweet-body profile-result"):
|
||||||
|
tdiv(class="tweet-header"):
|
||||||
|
a(class="tweet-avatar", href=("/" & user.username)):
|
||||||
|
genImg(user.getUserpic("_bigger"), class="avatar")
|
||||||
|
|
||||||
|
tdiv(class="tweet-name-row"):
|
||||||
|
tdiv(class="fullname-and-username"):
|
||||||
|
linkUser(user, class="fullname")
|
||||||
|
linkUser(user, class="username")
|
||||||
|
|
||||||
|
tdiv(class="tweet-content media-body"):
|
||||||
|
verbatim linkifyText(user.bio, prefs)
|
||||||
|
|
||||||
|
proc renderTimelineUsers*(results: Result[Profile]; prefs: Prefs): VNode =
|
||||||
|
buildHtml(tdiv(class="timeline")):
|
||||||
|
if not results.beginning:
|
||||||
|
renderNewer(results.query)
|
||||||
|
|
||||||
|
if results.content.len > 0:
|
||||||
|
for user in results.content:
|
||||||
|
renderUser(user, prefs)
|
||||||
|
renderOlder(results.query, results.minId)
|
||||||
|
elif results.beginning:
|
||||||
|
renderNoneFound()
|
||||||
|
else:
|
||||||
|
renderNoMore()
|
||||||
|
|
||||||
|
proc renderTimelineTweets*(results: Result[Tweet]; prefs: Prefs; path: string): VNode =
|
||||||
|
buildHtml(tdiv(class="timeline")):
|
||||||
|
if not results.beginning:
|
||||||
|
renderNewer(results.query)
|
||||||
|
|
||||||
|
if results.content.len == 0:
|
||||||
|
renderNoneFound()
|
||||||
|
else:
|
||||||
var threads: seq[string]
|
var threads: seq[string]
|
||||||
for tweet in timeline.content:
|
var retweets: seq[string]
|
||||||
if tweet.threadId in threads: continue
|
for tweet in results.content:
|
||||||
let thread = timeline.content.filterIt(threadFilter(it, tweet.threadId))
|
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 thread.len < 2:
|
||||||
renderTweet(tweet, prefs, path, class="timeline-tweet")
|
if tweet.retweet.isSome:
|
||||||
|
retweets &= tweet.id
|
||||||
|
renderTweet(tweet, prefs, path, showThread=tweet.hasThread)
|
||||||
else:
|
else:
|
||||||
renderThread(thread, prefs, path)
|
renderThread(thread, prefs, path)
|
||||||
threads &= tweet.threadId
|
threads &= tweet.threadId
|
||||||
|
|
||||||
proc renderTimeline*(timeline: Timeline; username: string; protected: bool;
|
if results.hasMore or results.query.kind != posts:
|
||||||
prefs: Prefs; path: string; multi=false): VNode =
|
renderOlder(results.query, results.minId)
|
||||||
buildHtml(tdiv):
|
|
||||||
if multi:
|
|
||||||
tdiv(class="multi-header"):
|
|
||||||
text username.replace(",", " | ")
|
|
||||||
|
|
||||||
if not protected:
|
|
||||||
renderSearchTabs(timeline, username)
|
|
||||||
if not timeline.beginning:
|
|
||||||
renderNewer(timeline, username)
|
|
||||||
|
|
||||||
if protected:
|
|
||||||
renderProtected(username)
|
|
||||||
elif timeline.content.len == 0:
|
|
||||||
renderNoneFound()
|
|
||||||
else:
|
|
||||||
renderTweets(timeline, prefs, path)
|
|
||||||
if timeline.hasMore or timeline.query.isSome:
|
|
||||||
renderOlder(timeline, username)
|
|
||||||
else:
|
else:
|
||||||
renderNoMore()
|
renderNoMore()
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import strutils, sequtils
|
import strutils, sequtils, strformat
|
||||||
import karax/[karaxdsl, vdom, vstyles]
|
import karax/[karaxdsl, vdom, vstyles]
|
||||||
|
|
||||||
import renderutils
|
import renderutils
|
||||||
|
@ -31,10 +31,15 @@ proc renderAlbum(tweet: Tweet): VNode =
|
||||||
let
|
let
|
||||||
groups = if tweet.photos.len < 3: @[tweet.photos]
|
groups = if tweet.photos.len < 3: @[tweet.photos]
|
||||||
else: tweet.photos.distribute(2)
|
else: tweet.photos.distribute(2)
|
||||||
class = if groups.len == 1 and groups[0].len == 1: "single-image"
|
|
||||||
else: ""
|
|
||||||
|
|
||||||
buildHtml(tdiv(class=("attachments " & class))):
|
if groups.len == 1 and groups[0].len == 1:
|
||||||
|
buildHtml(tdiv(class="single-image")):
|
||||||
|
tdiv(class="attachments gallery-row"):
|
||||||
|
a(href=getPicUrl(groups[0][0] & "?name=orig"), class="still-image",
|
||||||
|
target="_blank"):
|
||||||
|
genImg(groups[0][0])
|
||||||
|
else:
|
||||||
|
buildHtml(tdiv(class="attachments")):
|
||||||
for i, photos in groups:
|
for i, photos in groups:
|
||||||
let margin = if i > 0: ".25em" else: ""
|
let margin = if i > 0: ".25em" else: ""
|
||||||
let flex = if photos.len > 1 or groups.len > 1: "flex" else: "block"
|
let flex = if photos.len > 1 or groups.len > 1: "flex" else: "block"
|
||||||
|
@ -217,30 +222,29 @@ proc renderQuote(quote: Quote; prefs: Prefs): VNode =
|
||||||
text "Show this thread"
|
text "Show this thread"
|
||||||
|
|
||||||
proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class="";
|
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
|
var divClass = class
|
||||||
if index == total or last:
|
if index == total or last:
|
||||||
divClass = "thread-last " & class
|
divClass = "thread-last " & class
|
||||||
|
|
||||||
if not tweet.available:
|
if not tweet.available:
|
||||||
return buildHtml(tdiv(class=divClass)):
|
return buildHtml(tdiv(class=divClass & "unavailable timeline-item")):
|
||||||
tdiv(class="status-el unavailable"):
|
|
||||||
tdiv(class="unavailable-box"):
|
tdiv(class="unavailable-box"):
|
||||||
if tweet.tombstone.len > 0:
|
if tweet.tombstone.len > 0:
|
||||||
text tweet.tombstone
|
text tweet.tombstone
|
||||||
else:
|
else:
|
||||||
text "This tweet is unavailable"
|
text "This tweet is unavailable"
|
||||||
|
|
||||||
buildHtml(tdiv(class=divClass)):
|
buildHtml(tdiv(class=("timeline-item " & divClass))):
|
||||||
tdiv(class="status-el"):
|
a(class="tweet-link", href=getLink(tweet))
|
||||||
tdiv(class="status-body"):
|
tdiv(class="tweet-body"):
|
||||||
var views = ""
|
var views = ""
|
||||||
renderHeader(tweet)
|
renderHeader(tweet)
|
||||||
|
|
||||||
if index == 0 and tweet.reply.len > 0:
|
if index == 0 and tweet.reply.len > 0:
|
||||||
renderReply(tweet)
|
renderReply(tweet)
|
||||||
|
|
||||||
tdiv(class="status-content media-body"):
|
tdiv(class="tweet-content media-body"):
|
||||||
verbatim linkifyText(tweet.text, prefs)
|
verbatim linkifyText(tweet.text, prefs)
|
||||||
|
|
||||||
if tweet.quote.isSome:
|
if tweet.quote.isSome:
|
||||||
|
@ -261,6 +265,6 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class="";
|
||||||
if not prefs.hideTweetStats:
|
if not prefs.hideTweetStats:
|
||||||
renderStats(tweet.stats, views)
|
renderStats(tweet.stats, views)
|
||||||
|
|
||||||
if tweet.hasThread and "timeline" in class:
|
if showThread:
|
||||||
a(class="show-thread", href=getLink(tweet)):
|
a(class="show-thread", href=getLink(tweet)):
|
||||||
text "Show this thread"
|
text "Show this thread"
|
||||||
|
|
|
@ -31,7 +31,7 @@ class Tweet(object):
|
||||||
self.fullname = namerow + '.fullname'
|
self.fullname = namerow + '.fullname'
|
||||||
self.username = namerow + '.username'
|
self.username = namerow + '.username'
|
||||||
self.date = namerow + '.tweet-date'
|
self.date = namerow + '.tweet-date'
|
||||||
self.text = tweet + '.status-content.media-body'
|
self.text = tweet + '.tweet-content.media-body'
|
||||||
self.retweet = tweet + '.retweet'
|
self.retweet = tweet + '.retweet'
|
||||||
self.reply = tweet + '.replying-to'
|
self.reply = tweet + '.replying-to'
|
||||||
|
|
||||||
|
@ -50,7 +50,7 @@ class Profile(object):
|
||||||
|
|
||||||
|
|
||||||
class Timeline(object):
|
class Timeline(object):
|
||||||
newest = 'div[class="status-el show-more"]'
|
newest = 'div[class="timeline-item show-more"]'
|
||||||
older = 'div[class="show-more"]'
|
older = 'div[class="show-more"]'
|
||||||
end = '.timeline-end'
|
end = '.timeline-end'
|
||||||
none = '.timeline-none'
|
none = '.timeline-none'
|
||||||
|
@ -63,8 +63,8 @@ class Conversation(object):
|
||||||
after = '.after-tweet'
|
after = '.after-tweet'
|
||||||
replies = '.replies'
|
replies = '.replies'
|
||||||
thread = '.reply'
|
thread = '.reply'
|
||||||
tweet = '.status-el'
|
tweet = '.timeline-item'
|
||||||
tweet_text = '.status-content'
|
tweet_text = '.tweet-content'
|
||||||
|
|
||||||
|
|
||||||
class Poll(object):
|
class Poll(object):
|
||||||
|
@ -95,9 +95,9 @@ class BaseTestCase(BaseCase):
|
||||||
|
|
||||||
def search_username(self, username):
|
def search_username(self, username):
|
||||||
self.open_nitter()
|
self.open_nitter()
|
||||||
self.update_text('.search-panel input', username)
|
self.update_text('.search-bar input[type=text]', username)
|
||||||
self.submit('.search-panel form')
|
self.submit('.search-bar form')
|
||||||
|
|
||||||
|
|
||||||
def get_timeline_tweet(num=1):
|
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)
|
@parameterized.expand(short)
|
||||||
def test_short(self, username):
|
def test_short(self, username):
|
||||||
self.open_nitter(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.newest)
|
||||||
self.assert_element_absent(Timeline.older)
|
self.assert_element_absent(Timeline.older)
|
||||||
|
|
||||||
@parameterized.expand(no_more)
|
@parameterized.expand(no_more)
|
||||||
def test_no_more(self, username):
|
def test_no_more(self, username):
|
||||||
self.open_nitter(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_present(Timeline.newest)
|
||||||
self.assert_element_absent(Timeline.older)
|
self.assert_element_absent(Timeline.older)
|
||||||
|
|
||||||
@parameterized.expand(none_found)
|
@parameterized.expand(none_found)
|
||||||
def test_none_found(self, username):
|
def test_none_found(self, username):
|
||||||
self.open_nitter(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_present(Timeline.newest)
|
||||||
self.assert_element_absent(Timeline.older)
|
self.assert_element_absent(Timeline.older)
|
||||||
self.assert_element_absent(Timeline.end)
|
self.assert_element_absent(Timeline.end)
|
||||||
|
@ -59,7 +59,7 @@ class TweetTest(BaseTestCase):
|
||||||
@parameterized.expand(empty)
|
@parameterized.expand(empty)
|
||||||
def test_empty(self, username):
|
def test_empty(self, username):
|
||||||
self.open_nitter(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.newest)
|
||||||
self.assert_element_absent(Timeline.older)
|
self.assert_element_absent(Timeline.older)
|
||||||
self.assert_element_absent(Timeline.end)
|
self.assert_element_absent(Timeline.end)
|
||||||
|
|
|
@ -147,6 +147,6 @@ class TweetTest(BaseTestCase):
|
||||||
@parameterized.expand(reply)
|
@parameterized.expand(reply)
|
||||||
def test_reply(self, tweet, username, reply):
|
def test_reply(self, tweet, username, reply):
|
||||||
self.open_nitter(tweet)
|
self.open_nitter(tweet)
|
||||||
tweet = get_timeline_tweet(1)
|
tweet = get_timeline_tweet(2)
|
||||||
self.assert_text(username, tweet.username)
|
self.assert_text(username, tweet.username)
|
||||||
self.assert_text('Replying to ' + reply, tweet.reply)
|
self.assert_text('Replying to ' + reply, tweet.reply)
|
||||||
|
|
Loading…
Reference in New Issue