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