From 4584932e4f0d03e0e9b212ef2b8b38fad4a6ffcf Mon Sep 17 00:00:00 2001 From: taskylizard <75871323+taskylizard@users.noreply.github.com> Date: Sun, 19 May 2024 05:39:08 +0000 Subject: [PATCH] Pull from https://github.com/PrivacyDevel/nitter/pull/50 --- src/api.nim | 53 ++++++++++++ src/apiutils.nim | 29 +++++-- src/config.nim | 5 ++ src/consts.nim | 21 +++++ src/formatters.nim | 2 - src/nitter.nim | 7 +- src/parser.nim | 174 +++++++++++++++++++++++++++++++++++++- src/query.nim | 7 ++ src/routes/rss.nim | 5 +- src/routes/status.nim | 25 +++++- src/routes/timeline.nim | 63 ++++++++------ src/sass/tweet/_base.scss | 1 + src/types.nim | 9 +- src/views/general.nim | 4 +- src/views/profile.nim | 16 ++-- src/views/search.nim | 13 ++- src/views/tweet.nim | 21 +++-- 17 files changed, 388 insertions(+), 67 deletions(-) diff --git a/src/api.nim b/src/api.nim index d6a4564..ef60812 100644 --- a/src/api.nim +++ b/src/api.nim @@ -69,6 +69,23 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} let url = graphListMembers ? {"variables": $variables, "features": gqlFeatures} result = parseGraphListMembers(await fetchRaw(url, Api.listMembers), after) +proc getFavorites*(id: string; cfg: Config; after=""): Future[Profile] {.async.} = + if id.len == 0: return + var + variables = %*{ + "userId": id, + "includePromotedContent":false, + "withClientEventToken":false, + "withBirdwatchNotes":false, + "withVoice":true, + "withV2Timeline":false + } + if after.len > 0: + variables["cursor"] = % after + let + url = consts.favorites ? {"variables": $variables, "features": gqlFeatures} + result = parseGraphTimeline(await fetch(url, Api.favorites), after) + proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} = if id.len == 0: return let @@ -86,6 +103,42 @@ proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} = js = await fetch(graphTweet ? params, Api.tweetDetail) result = parseGraphConversation(js, id) +proc getGraphFavoriters*(id: string; after=""): Future[UsersTimeline] {.async.} = + if id.len == 0: return + let + cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" + variables = reactorsVariables % [id, cursor] + params = {"variables": variables, "features": gqlFeatures} + js = await fetch(graphFavoriters ? params, Api.favoriters) + result = parseGraphFavoritersTimeline(js, id) + +proc getGraphRetweeters*(id: string; after=""): Future[UsersTimeline] {.async.} = + if id.len == 0: return + let + cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" + variables = reactorsVariables % [id, cursor] + params = {"variables": variables, "features": gqlFeatures} + js = await fetch(graphRetweeters ? params, Api.retweeters) + result = parseGraphRetweetersTimeline(js, id) + +proc getGraphFollowing*(id: string; after=""): Future[UsersTimeline] {.async.} = + if id.len == 0: return + let + cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" + variables = followVariables % [id, cursor] + params = {"variables": variables, "features": gqlFeatures} + js = await fetch(graphFollowing ? params, Api.following) + result = parseGraphFollowTimeline(js, id) + +proc getGraphFollowers*(id: string; after=""): Future[UsersTimeline] {.async.} = + if id.len == 0: return + let + cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" + variables = followVariables % [id, cursor] + params = {"variables": variables, "features": gqlFeatures} + js = await fetch(graphFollowers ? params, Api.followers) + result = parseGraphFollowTimeline(js, id) + proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} = result = (await getGraphTweet(id, after)).replies result.beginning = after.len == 0 diff --git a/src/apiutils.nim b/src/apiutils.nim index 1ff05eb..774dcb5 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -3,6 +3,7 @@ import httpclient, asyncdispatch, options, strutils, uri, times, math, tables import jsony, packedjson, zippy, oauth1 import types, auth, consts, parserutils, http_pool import experimental/types/common +import config const rlRemaining = "x-rate-limit-remaining" @@ -48,7 +49,7 @@ proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string = proc genHeaders*(url, oauthToken, oauthTokenSecret: string): HttpHeaders = let header = getOauthHeader(url, oauthToken, oauthTokenSecret) - + result = newHttpHeaders({ "connection": "keep-alive", "authorization": header, @@ -61,7 +62,14 @@ proc genHeaders*(url, oauthToken, oauthTokenSecret: string): HttpHeaders = "DNT": "1" }) -template fetchImpl(result, fetchBody) {.dirty.} = +template updateAccount() = + if resp.headers.hasKey(rlRemaining): + let + remaining = parseInt(resp.headers[rlRemaining]) + reset = parseInt(resp.headers[rlReset]) + account.setRateLimit(api, remaining, reset) + +template fetchImpl(result, additional_headers, fetchBody) {.dirty.} = once: pool = HttpPool() @@ -72,13 +80,19 @@ template fetchImpl(result, fetchBody) {.dirty.} = try: var resp: AsyncResponse - pool.use(genHeaders($url, account.oauthToken, account.oauthSecret)): + var headers = genHeaders($url, account.oauthToken, account.oauthSecret) + for key, value in additional_headers.pairs(): + headers.add(key, value) + pool.use(headers): template getContent = resp = await c.get($url) result = await resp.body getContent() + if resp.status == $Http429: + raise rateLimitError() + if resp.status == $Http503: badClient = true raise newException(BadClientError, "Bad client") @@ -133,10 +147,11 @@ template retry(bod) = echo "[accounts] Rate limited, retrying ", api, " request..." bod -proc fetch*(url: Uri; api: Api): Future[JsonNode] {.async.} = +proc fetch*(url: Uri; api: Api; additional_headers: HttpHeaders = newHttpHeaders()): Future[JsonNode] {.async.} = + retry: var body: string - fetchImpl body: + fetchImpl(body, additional_headers): if body.startsWith('{') or body.startsWith('['): result = parseJson(body) else: @@ -149,9 +164,9 @@ proc fetch*(url: Uri; api: Api): Future[JsonNode] {.async.} = invalidate(account) raise rateLimitError() -proc fetchRaw*(url: Uri; api: Api): Future[string] {.async.} = +proc fetchRaw*(url: Uri; api: Api; additional_headers: HttpHeaders = newHttpHeaders()): Future[string] {.async.} = retry: - fetchImpl result: + fetchImpl(result, additional_headers): if not (result.startsWith('{') or result.startsWith('[')): echo resp.status, ": ", result, " --- url: ", url result.setLen(0) diff --git a/src/config.nim b/src/config.nim index 1b05ffe..2c216a2 100644 --- a/src/config.nim +++ b/src/config.nim @@ -1,6 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-only import parsecfg except Config import types, strutils +from os import getEnv proc get*[T](config: parseCfg.Config; section, key: string; default: T): T = let val = config.getSectionValue(section, key) @@ -44,3 +45,7 @@ proc getConfig*(path: string): (Config, parseCfg.Config) = ) return (conf, cfg) + + +let configPath = getEnv("NITTER_CONF_FILE", "./nitter.conf") +let (cfg*, fullCfg*) = getConfig(configPath) diff --git a/src/consts.nim b/src/consts.nim index e1c35e6..0a24469 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -10,6 +10,8 @@ const photoRail* = api / "1.1/statuses/media_timeline.json" + timelineApi = api / "2/timeline" + graphql = api / "graphql" graphUser* = graphql / "u7wQyGi6oExe8_TRWGMq4Q/UserResultByScreenNameQuery" graphUserById* = graphql / "oPppcargziU1uDQHAUmH-A/UserResultByIdQuery" @@ -23,6 +25,11 @@ const graphListBySlug* = graphql / "-kmqNvm5Y-cVrfvBy6docg/ListBySlug" graphListMembers* = graphql / "P4NpVZDqUD_7MEM84L-8nw/ListMembers" graphListTweets* = graphql / "BbGLL1ZfMibdFNWlk7a0Pw/ListTimeline" + graphFavoriters* = graphql / "mDc_nU8xGv0cLRWtTaIEug/Favoriters" + graphRetweeters* = graphql / "RCR9gqwYD1NEgi9FWzA50A/Retweeters" + graphFollowers* = graphql / "EAqBhgcGr_qPOzhS4Q3scQ/Followers" + graphFollowing* = graphql / "JPZiqKjET7_M1r5Tlr8pyA/Following" + favorites* = graphql / "eSSNbhECHHWWALkkQq-YTA/Likes" timelineParams* = { "include_can_media_tag": "1", @@ -43,6 +50,7 @@ const gqlFeatures* = """{ "android_graphql_skip_api_media_color_palette": false, "blue_business_profile_image_shape_enabled": false, + "c9s_tweet_anatomy_moderator_badge_enabled": false, "creator_subscriptions_subscription_count_enabled": false, "creator_subscriptions_tweet_preview_api_enabled": true, "freedom_of_speech_not_reach_fetch_enabled": false, @@ -64,6 +72,7 @@ const "responsive_web_twitter_article_tweet_consumption_enabled": false, "responsive_web_twitter_blue_verified_badge_is_enabled": true, "rweb_lists_timeline_redesign_enabled": true, + "rweb_video_timestamps_enabled": true, "spaces_2022_h2_clipping": true, "spaces_2022_h2_spaces_communities": true, "standardized_nudges_misinfo": false, @@ -114,3 +123,15 @@ const "rest_id": "$1", $2 "count": 20 }""" + + reactorsVariables* = """{ + "tweetId" : "$1", $2 + "count" : 20, + "includePromotedContent": false +}""" + + followVariables* = """{ + "userId" : "$1", $2 + "count" : 20, + "includePromotedContent": false +}""" diff --git a/src/formatters.nim b/src/formatters.nim index 8267f23..3630917 100644 --- a/src/formatters.nim +++ b/src/formatters.nim @@ -82,8 +82,6 @@ proc proxifyVideo*(manifest: string; proxy: bool): string = for line in manifest.splitLines: let url = if line.startsWith("#EXT-X-MAP:URI"): line[16 .. ^2] - elif line.startsWith("#EXT-X-MEDIA") and "URI=" in line: - line[line.find("URI=") + 5 .. -1 + line.find("\"", start= 5 + line.find("URI="))] else: line if url.startsWith('/'): let path = "https://video.twimg.com" & url diff --git a/src/nitter.nim b/src/nitter.nim index 744eff9..f976db2 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -1,5 +1,6 @@ # SPDX-License-Identifier: AGPL-3.0-only import asyncdispatch, strformat, logging +import config from net import Port from htmlgen import a from os import getEnv @@ -10,15 +11,12 @@ import types, config, prefs, formatters, redis_cache, http_pool, auth import views/[general, about] import routes/[ preferences, timeline, status, media, search, rss, list, debug, - twitter_api, unsupported, embed, resolver, router_utils] + unsupported, embed, resolver, router_utils] const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances" const issuesUrl = "https://github.com/zedeus/nitter/issues" let - configPath = getEnv("NITTER_CONF_FILE", "./nitter.conf") - (cfg, fullCfg) = getConfig(configPath) - accountsPath = getEnv("NITTER_ACCOUNTS_FILE", "./guest_accounts.json") initAccountPool(cfg, accountsPath) @@ -53,7 +51,6 @@ createSearchRouter(cfg) createMediaRouter(cfg) createEmbedRouter(cfg) createRssRouter(cfg) -createTwitterApiRouter(cfg) createDebugRouter(cfg) settings: diff --git a/src/parser.nim b/src/parser.nim index ec856a6..95a1fbc 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -3,6 +3,7 @@ import strutils, options, times, math import packedjson, packedjson/deserialiser import types, parserutils, utils import experimental/parser/unifiedcard +import std/tables proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet @@ -32,7 +33,8 @@ proc parseGraphUser(js: JsonNode): User = var user = js{"user_result", "result"} if user.isNull: user = ? js{"user_results", "result"} - result = parseUser(user{"legacy"}, user{"rest_id"}.getStr) + + result = parseUser(user{"legacy"}) if result.verifiedType == VerifiedType.none and user{"is_blue_verified"}.getBool(false): result.verifiedType = blue @@ -236,8 +238,11 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet = # graphql with rt, js{"retweeted_status_result", "result"}: # needed due to weird edgecase where the actual tweet data isn't included - if "legacy" in rt: - result.retweet = some parseGraphTweet(rt) + var rt_tweet = rt + if "tweet" in rt: + rt_tweet = rt{"tweet"} + if "legacy" in rt_tweet: + result.retweet = some parseGraphTweet(rt_tweet) return if jsCard.kind != JNull: @@ -289,6 +294,121 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet = result.text.removeSuffix(" Learn more.") result.available = false +proc parseLegacyTweet(js: JsonNode): Tweet = + result = parseTweet(js, js{"card"}) + if not result.isNil and result.available: + result.user = parseUser(js{"user"}) + + if result.quote.isSome: + result.quote = some parseLegacyTweet(js{"quoted_status"}) + +proc parseTweetSearch*(js: JsonNode; after=""): Timeline = + result.beginning = after.len == 0 + + if js.kind == JNull or "modules" notin js or js{"modules"}.len == 0: + return + + for item in js{"modules"}: + with tweet, item{"status", "data"}: + let parsed = parseLegacyTweet(tweet) + + if parsed.retweet.isSome: + parsed.retweet = some parseLegacyTweet(tweet{"retweeted_status"}) + + result.content.add @[parsed] + + if result.content.len > 0: + result.bottom = $(result.content[^1][0].id - 1) + +proc finalizeTweet(global: GlobalObjects; id: string): Tweet = + let intId = if id.len > 0: parseBiggestInt(id) else: 0 + result = global.tweets.getOrDefault(id, Tweet(id: intId)) + + if result.quote.isSome: + let quote = get(result.quote).id + if $quote in global.tweets: + result.quote = some global.tweets[$quote] + else: + result.quote = some Tweet() + + if result.retweet.isSome: + let rt = get(result.retweet).id + if $rt in global.tweets: + result.retweet = some finalizeTweet(global, $rt) + else: + result.retweet = some Tweet() + +proc parsePin(js: JsonNode; global: GlobalObjects): Tweet = + let pin = js{"pinEntry", "entry", "entryId"}.getStr + if pin.len == 0: return + + let id = pin.getId + if id notin global.tweets: return + + global.tweets[id].pinned = true + return finalizeTweet(global, id) + +proc parseGlobalObjects(js: JsonNode): GlobalObjects = + result = GlobalObjects() + let + tweets = ? js{"globalObjects", "tweets"} + users = ? js{"globalObjects", "users"} + + for k, v in users: + result.users[k] = parseUser(v, k) + + for k, v in tweets: + var tweet = parseTweet(v, v{"card"}) + if tweet.user.id in result.users: + tweet.user = result.users[tweet.user.id] + result.tweets[k] = tweet + +proc parseInstructions(res: var Profile; global: GlobalObjects; js: JsonNode) = + if js.kind != JArray or js.len == 0: + return + + for i in js: + if res.tweets.beginning and i{"pinEntry"}.notNull: + with pin, parsePin(i, global): + res.pinned = some pin + + with r, i{"replaceEntry", "entry"}: + if "top" in r{"entryId"}.getStr: + res.tweets.top = r.getCursor + elif "bottom" in r{"entryId"}.getStr: + res.tweets.bottom = r.getCursor + +proc parseTimeline*(js: JsonNode; after=""): Profile = + result = Profile(tweets: Timeline(beginning: after.len == 0)) + let global = parseGlobalObjects(? js) + + let instructions = ? js{"timeline", "instructions"} + if instructions.len == 0: return + + result.parseInstructions(global, instructions) + + var entries: JsonNode + for i in instructions: + if "addEntries" in i: + entries = i{"addEntries", "entries"} + + for e in ? entries: + let entry = e{"entryId"}.getStr + if "tweet" in entry or entry.startsWith("sq-I-t") or "tombstone" in entry: + let tweet = finalizeTweet(global, e.getEntryId) + if not tweet.available: continue + result.tweets.content.add tweet + elif "cursor-top" in entry: + result.tweets.top = e.getCursor + elif "cursor-bottom" in entry: + result.tweets.bottom = e.getCursor + elif entry.startsWith("sq-cursor"): + with cursor, e{"content", "operation", "cursor"}: + if cursor{"cursorType"}.getStr == "Bottom": + result.tweets.bottom = cursor{"value"}.getStr + else: + result.tweets.top = cursor{"value"}.getStr + proc parsePhotoRail*(js: JsonNode): PhotoRail = with error, js{"error"}: if error.getStr == "Not authorized.": @@ -415,7 +535,8 @@ proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile = let instructions = if root == "list": ? js{"data", "list", "timeline_response", "timeline", "instructions"} - else: ? js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"} + elif root == "user": ? js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"} + else: ? js{"data", "user", "result", "timeline", "timeline", "instructions"} if instructions.len == 0: return @@ -435,6 +556,21 @@ proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile = result.tweets.content.add thread.content elif entryId.startsWith("cursor-bottom"): result.tweets.bottom = e{"content", "value"}.getStr + # TODO cleanup + if i{"type"}.getStr == "TimelineAddEntries": + for e in i{"entries"}: + let entryId = e{"entryId"}.getStr + if entryId.startsWith("tweet"): + with tweetResult, e{"content", "itemContent", "tweet_results", "result"}: + let tweet = parseGraphTweet(tweetResult, false) + if not tweet.available: + tweet.id = parseBiggestInt(entryId.getId()) + result.tweets.content.add tweet + elif "-conversation-" in entryId or entryId.startsWith("homeConversation"): + let (thread, self) = parseGraphThread(e) + result.tweets.content.add thread.content + elif entryId.startsWith("cursor-bottom"): + result.tweets.bottom = e{"content", "value"}.getStr if after.len == 0 and i{"__typename"}.getStr == "TimelinePinEntry": with tweetResult, i{"entry", "content", "content", "tweetResult", "result"}: let tweet = parseGraphTweet(tweetResult, false) @@ -445,6 +581,36 @@ proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile = tweet.id = parseBiggestInt(entryId) result.pinned = some tweet +proc parseGraphUsersTimeline(timeline: JsonNode; after=""): UsersTimeline = + result = UsersTimeline(beginning: after.len == 0) + + let instructions = ? timeline{"instructions"} + + if instructions.len == 0: + return + + for i in instructions: + if i{"type"}.getStr == "TimelineAddEntries": + for e in i{"entries"}: + let entryId = e{"entryId"}.getStr + if entryId.startsWith("user"): + with graphUser, e{"content", "itemContent"}: + let user = parseGraphUser(graphUser) + result.content.add user + elif entryId.startsWith("cursor-bottom"): + result.bottom = e{"content", "value"}.getStr + elif entryId.startsWith("cursor-top"): + result.top = e{"content", "value"}.getStr + +proc parseGraphFavoritersTimeline*(js: JsonNode; root: string; after=""): UsersTimeline = + return parseGraphUsersTimeline(js{"data", "favoriters_timeline", "timeline"}, after) + +proc parseGraphRetweetersTimeline*(js: JsonNode; root: string; after=""): UsersTimeline = + return parseGraphUsersTimeline(js{"data", "retweeters_timeline", "timeline"}, after) + +proc parseGraphFollowTimeline*(js: JsonNode; root: string; after=""): UsersTimeline = + return parseGraphUsersTimeline(js{"data", "user", "result", "timeline", "timeline"}, after) + proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] = result = Result[T](beginning: after.len == 0) diff --git a/src/query.nim b/src/query.nim index 06e1da2..b5d79d9 100644 --- a/src/query.nim +++ b/src/query.nim @@ -40,6 +40,13 @@ proc getMediaQuery*(name: string): Query = sep: "OR" ) + +proc getFavoritesQuery*(name: string): Query = + Query( + kind: favorites, + fromUser: @[name] + ) + proc getReplyQuery*(name: string): Query = Query( kind: replies, diff --git a/src/routes/rss.nim b/src/routes/rss.nim index 447f4ad..0896536 100644 --- a/src/routes/rss.nim +++ b/src/routes/rss.nim @@ -23,7 +23,7 @@ proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async. names = getNames(name) if names.len == 1: - profile = await fetchProfile(after, query, skipRail=true, skipPinned=true) + profile = await fetchProfile(after, query, cfg, skipRail=true, skipPinned=true) else: var q = query q.fromUser = names @@ -102,7 +102,7 @@ proc createRssRouter*(cfg: Config) = get "/@name/@tab/rss": cond cfg.enableRss cond '.' notin @"name" - cond @"tab" in ["with_replies", "media", "search"] + cond @"tab" in ["with_replies", "media", "favorites", "search"] let name = @"name" tab = @"tab" @@ -110,6 +110,7 @@ proc createRssRouter*(cfg: Config) = case tab of "with_replies": getReplyQuery(name) of "media": getMediaQuery(name) + of "favorites": getFavoritesQuery(name) of "search": initQuery(params(request), name=name) else: Query(fromUser: @[name]) diff --git a/src/routes/status.nim b/src/routes/status.nim index 7e89220..036eca0 100644 --- a/src/routes/status.nim +++ b/src/routes/status.nim @@ -5,7 +5,7 @@ import jester, karax/vdom import router_utils import ".."/[types, formatters, api] -import ../views/[general, status] +import ../views/[general, status, search] export uri, sequtils, options, sugar export router_utils @@ -14,6 +14,29 @@ export status proc createStatusRouter*(cfg: Config) = router status: + get "/@name/status/@id/@reactors": + cond '.' notin @"name" + let id = @"id" + + if id.len > 19 or id.any(c => not c.isDigit): + resp Http404, showError("Invalid tweet ID", cfg) + + let prefs = cookiePrefs() + + # used for the infinite scroll feature + if @"scroll".len > 0: + let replies = await getReplies(id, getCursor()) + if replies.content.len == 0: + resp Http404, "" + resp $renderReplies(replies, prefs, getPath()) + + if @"reactors" == "favoriters": + resp renderMain(renderUserList(await getGraphFavoriters(id, getCursor()), prefs), + request, cfg, prefs) + elif @"reactors" == "retweeters": + resp renderMain(renderUserList(await getGraphRetweeters(id, getCursor()), prefs), + request, cfg, prefs) + get "/@name/status/@id/?": cond '.' notin @"name" let id = @"id" diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index 3568ab7..b71e182 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -16,6 +16,7 @@ proc getQuery*(request: Request; tab, name: string): Query = case tab of "with_replies": getReplyQuery(name) of "media": getMediaQuery(name) + of "favorites": getFavoritesQuery(name) of "search": initQuery(params(request), name=name) else: Query(fromUser: @[name]) @@ -27,7 +28,7 @@ template skipIf[T](cond: bool; default; body: Future[T]): Future[T] = else: body -proc fetchProfile*(after: string; query: Query; skipRail=false; +proc fetchProfile*(after: string; query: Query; cfg: Config; skipRail=false; skipPinned=false): Future[Profile] {.async.} = let name = query.fromUser[0] @@ -56,6 +57,7 @@ proc fetchProfile*(after: string; query: Query; skipRail=false; of posts: await getGraphUserTweets(userId, TimelineKind.tweets, after) of replies: await getGraphUserTweets(userId, TimelineKind.replies, after) of media: await getGraphUserTweets(userId, TimelineKind.media, after) + of favorites: await getFavorites(userId, cfg, after) else: Profile(tweets: await getGraphTweetSearch(query, after)) result.user = await user @@ -71,7 +73,7 @@ proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs; html = renderTweetSearch(timeline, prefs, getPath()) return renderMain(html, request, cfg, prefs, "Multi", rss=rss) - var profile = await fetchProfile(after, query, skipPinned=prefs.hidePins) + var profile = await fetchProfile(after, query, cfg, skipPinned=prefs.hidePins) template u: untyped = profile.user if u.suspended: @@ -79,7 +81,7 @@ proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs; if profile.user.id.len == 0: return - let pHtml = renderProfile(profile, prefs, getPath()) + let pHtml = renderProfile(profile, cfg, prefs, getPath()) result = renderMain(pHtml, request, cfg, prefs, pageTitle(u), pageDesc(u), rss=rss, images = @[u.getUserPic("_400x400")], banner=u.banner) @@ -109,35 +111,42 @@ proc createTimelineRouter*(cfg: Config) = get "/@name/?@tab?/?": cond '.' notin @"name" cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"] - cond @"tab" in ["with_replies", "media", "search", ""] + cond @"tab" in ["with_replies", "media", "search", "favorites", "following", "followers", ""] let prefs = cookiePrefs() after = getCursor() names = getNames(@"name") + tab = @"tab" - var query = request.getQuery(@"tab", @"name") - if names.len != 1: - query.fromUser = names - - # used for the infinite scroll feature - if @"scroll".len > 0: - if query.fromUser.len != 1: - var timeline = await getGraphTweetSearch(query, after) - if timeline.content.len == 0: resp Http404 - timeline.beginning = true - resp $renderTweetSearch(timeline, prefs, getPath()) + case tab: + of "followers": + resp renderMain(renderUserList(await getGraphFollowers(await getUserId(@"name"), getCursor()), prefs), request, cfg, prefs) + of "following": + resp renderMain(renderUserList(await getGraphFollowing(await getUserId(@"name"), getCursor()), prefs), request, cfg, prefs) else: - var profile = await fetchProfile(after, query, skipRail=true) - if profile.tweets.content.len == 0: resp Http404 - profile.tweets.beginning = true - resp $renderTimelineTweets(profile.tweets, prefs, getPath()) + var query = request.getQuery(@"tab", @"name") + if names.len != 1: + query.fromUser = names - let rss = - if @"tab".len == 0: - "/$1/rss" % @"name" - elif @"tab" == "search": - "/$1/search/rss?$2" % [@"name", genQueryUrl(query)] - else: - "/$1/$2/rss" % [@"name", @"tab"] + # used for the infinite scroll feature + if @"scroll".len > 0: + if query.fromUser.len != 1: + var timeline = await getGraphTweetSearch(query, after) + if timeline.content.len == 0: resp Http404 + timeline.beginning = true + resp $renderTweetSearch(timeline, prefs, getPath()) + else: + var profile = await fetchProfile(after, query, cfg, skipRail=true) + if profile.tweets.content.len == 0: resp Http404 + profile.tweets.beginning = true + resp $renderTimelineTweets(profile.tweets, prefs, getPath()) - respTimeline(await showTimeline(request, query, cfg, prefs, rss, after)) + let rss = + if @"tab".len == 0: + "/$1/rss" % @"name" + elif @"tab" == "search": + "/$1/search/rss?$2" % [@"name", genQueryUrl(query)] + else: + "/$1/$2/rss" % [@"name", @"tab"] + + respTimeline(await showTimeline(request, query, cfg, prefs, rss, after)) diff --git a/src/sass/tweet/_base.scss b/src/sass/tweet/_base.scss index 69f51c0..3431a7b 100644 --- a/src/sass/tweet/_base.scss +++ b/src/sass/tweet/_base.scss @@ -207,6 +207,7 @@ padding-top: 5px; min-width: 1em; margin-right: 0.8em; + pointer-events: all; } .show-thread { diff --git a/src/types.nim b/src/types.nim index ddbebdf..a99aed5 100644 --- a/src/types.nim +++ b/src/types.nim @@ -23,9 +23,14 @@ type listTweets userRestId userScreenName + favorites userTweets userTweetsAndReplies userMedia + favoriters + retweeters + following + followers RateLimit* = object remaining*: int @@ -111,7 +116,7 @@ type variants*: seq[VideoVariant] QueryKind* = enum - posts, replies, media, users, tweets, userList + posts, replies, media, users, tweets, userList, favorites Query* = object kind*: QueryKind @@ -231,6 +236,7 @@ type replies*: Result[Chain] Timeline* = Result[Tweets] + UsersTimeline* = Result[User] Profile* = object user*: User @@ -276,6 +282,7 @@ type redisConns*: int redisMaxConns*: int redisPassword*: string + redisDb*: int Rss* = object feed*, cursor*: string diff --git a/src/views/general.nim b/src/views/general.nim index 35efb0b..87d30f2 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -32,6 +32,8 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode = if cfg.enableRss and rss.len > 0: icon "rss-feed", title="RSS Feed", href=rss icon "bird", title="Open in Twitter", href=canonical + a(href="https://liberapay.com/zedeus"): verbatim lp + icon "info", title="About", href="/about" icon "cog", title="Preferences", href=("/settings?referer=" & encodeUrl(path)) proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; @@ -71,7 +73,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed") if prefs.hlsPlayback: - script(src="/js/hls.min.js", `defer`="") + script(src="/js/hls.light.min.js", `defer`="") script(src="/js/hlsPlayback.js", `defer`="") if prefs.infiniteScroll: diff --git a/src/views/profile.nim b/src/views/profile.nim index 8f67f5a..2ec79f7 100644 --- a/src/views/profile.nim +++ b/src/views/profile.nim @@ -13,7 +13,7 @@ proc renderStat(num: int; class: string; text=""): VNode = text insertSep($num, ',') proc renderUserCard*(user: User; prefs: Prefs): VNode = - buildHtml(tdiv(class="profile-card", "data-profile-id" = $user.id)): + buildHtml(tdiv(class="profile-card")): tdiv(class="profile-card-info"): let url = getPicUrl(user.getUserPic()) @@ -58,10 +58,14 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode = tdiv(class="profile-card-extra-links"): ul(class="profile-statlist"): - renderStat(user.tweets, "posts", text="Tweets") - renderStat(user.following, "following") - renderStat(user.followers, "followers") - renderStat(user.likes, "likes") + a(href="/" & user.username): + renderStat(user.tweets, "posts", text="Tweets") + a(href="/" & user.username & "/following"): + renderStat(user.following, "following") + a(href="/" & user.username & "/followers"): + renderStat(user.followers, "followers") + a(href="/" & user.username & "/favorites"): + renderStat(user.likes, "likes") proc renderPhotoRail(profile: Profile): VNode = let count = insertSep($profile.user.media, ',') @@ -99,7 +103,7 @@ proc renderProtected(username: string): VNode = h2: text "This account's tweets are protected." p: text &"Only confirmed followers have access to @{username}'s tweets." -proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode = +proc renderProfile*(profile: var Profile; cfg: Config; prefs: Prefs; path: string): VNode = profile.tweets.query.fromUser = @[profile.user.username] buildHtml(tdiv(class="profile-tabs")): diff --git a/src/views/search.nim b/src/views/search.nim index 401e6da..0e5e808 100644 --- a/src/views/search.nim +++ b/src/views/search.nim @@ -3,7 +3,7 @@ import strutils, strformat, sequtils, unicode, tables, options import karax/[karaxdsl, vdom] import renderutils, timeline -import ".."/[types, query] +import ".."/[types, query, config] const toggles = { "nativeretweets": "Retweets", @@ -29,7 +29,7 @@ proc renderSearch*(): VNode = placeholder="Enter username...", dir="auto") button(`type`="submit"): icon "search" -proc renderProfileTabs*(query: Query; username: string): VNode = +proc renderProfileTabs*(query: Query; username: string; cfg: Config): VNode = let link = "/" & username buildHtml(ul(class="tab")): li(class=query.getTabClass(posts)): @@ -38,6 +38,8 @@ proc renderProfileTabs*(query: Query; username: string): VNode = a(href=(link & "/with_replies")): text "Tweets & Replies" li(class=query.getTabClass(media)): a(href=(link & "/media")): text "Media" + li(class=query.getTabClass(favorites)): + a(href=(link & "/favorites")): text "Likes" li(class=query.getTabClass(tweets)): a(href=(link & "/search")): text "Search" @@ -97,7 +99,7 @@ proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string; text query.fromUser.join(" | ") if query.fromUser.len > 0: - renderProfileTabs(query, query.fromUser.join(",")) + renderProfileTabs(query, query.fromUser.join(","), cfg) if query.fromUser.len == 0 or query.kind == tweets: tdiv(class="timeline-header"): @@ -118,3 +120,8 @@ proc renderUserSearch*(results: Result[User]; prefs: Prefs): VNode = renderSearchTabs(results.query) renderTimelineUsers(results, prefs) + +proc renderUserList*(results: Result[User]; prefs: Prefs): VNode = + buildHtml(tdiv(class="timeline-container")): + tdiv(class="timeline-header") + renderTimelineUsers(results, prefs) diff --git a/src/views/tweet.nim b/src/views/tweet.nim index 2fe4ac9..13b4a24 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -180,14 +180,19 @@ func formatStat(stat: int): string = if stat > 0: insertSep($stat, ',') else: "" -proc renderStats(stats: TweetStats; views: string): VNode = +proc renderStats(stats: TweetStats; views: string; tweet: Tweet): VNode = buildHtml(tdiv(class="tweet-stats")): - span(class="tweet-stat"): icon "comment", formatStat(stats.replies) - span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets) - span(class="tweet-stat"): icon "quote", formatStat(stats.quotes) - span(class="tweet-stat"): icon "heart", formatStat(stats.likes) - if views.len > 0: - span(class="tweet-stat"): icon "play", insertSep(views, ',') + a(href=getLink(tweet)): + span(class="tweet-stat"): icon "comment", formatStat(stats.replies) + a(href=getLink(tweet, false) & "/retweeters"): + span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets) + a(href="/search?q=quoted_tweet_id:" & $tweet.id): + span(class="tweet-stat"): icon "quote", formatStat(stats.quotes) + a(href=getLink(tweet, false) & "/favoriters"): + span(class="tweet-stat"): icon "heart", formatStat(stats.likes) + a(href=getLink(tweet)): + if views.len > 0: + span(class="tweet-stat"): icon "play", insertSep(views, ',') proc renderReply(tweet: Tweet): VNode = buildHtml(tdiv(class="replying-to")): @@ -345,7 +350,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; renderMediaTags(tweet.mediaTags) if not prefs.hideTweetStats: - renderStats(tweet.stats, views) + renderStats(tweet.stats, views, tweet) if showThread: a(class="show-thread", href=("/i/status/" & $tweet.threadId)):