This commit is contained in:
parent
3855af14f1
commit
4584932e4f
17 changed files with 388 additions and 67 deletions
53
src/api.nim
53
src/api.nim
|
@ -69,6 +69,23 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.}
|
||||||
let url = graphListMembers ? {"variables": $variables, "features": gqlFeatures}
|
let url = graphListMembers ? {"variables": $variables, "features": gqlFeatures}
|
||||||
result = parseGraphListMembers(await fetchRaw(url, Api.listMembers), after)
|
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.} =
|
proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} =
|
||||||
if id.len == 0: return
|
if id.len == 0: return
|
||||||
let
|
let
|
||||||
|
@ -86,6 +103,42 @@ proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} =
|
||||||
js = await fetch(graphTweet ? params, Api.tweetDetail)
|
js = await fetch(graphTweet ? params, Api.tweetDetail)
|
||||||
result = parseGraphConversation(js, id)
|
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.} =
|
proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} =
|
||||||
result = (await getGraphTweet(id, after)).replies
|
result = (await getGraphTweet(id, after)).replies
|
||||||
result.beginning = after.len == 0
|
result.beginning = after.len == 0
|
||||||
|
|
|
@ -3,6 +3,7 @@ import httpclient, asyncdispatch, options, strutils, uri, times, math, tables
|
||||||
import jsony, packedjson, zippy, oauth1
|
import jsony, packedjson, zippy, oauth1
|
||||||
import types, auth, consts, parserutils, http_pool
|
import types, auth, consts, parserutils, http_pool
|
||||||
import experimental/types/common
|
import experimental/types/common
|
||||||
|
import config
|
||||||
|
|
||||||
const
|
const
|
||||||
rlRemaining = "x-rate-limit-remaining"
|
rlRemaining = "x-rate-limit-remaining"
|
||||||
|
@ -61,7 +62,14 @@ proc genHeaders*(url, oauthToken, oauthTokenSecret: string): HttpHeaders =
|
||||||
"DNT": "1"
|
"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:
|
once:
|
||||||
pool = HttpPool()
|
pool = HttpPool()
|
||||||
|
|
||||||
|
@ -72,13 +80,19 @@ template fetchImpl(result, fetchBody) {.dirty.} =
|
||||||
|
|
||||||
try:
|
try:
|
||||||
var resp: AsyncResponse
|
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 =
|
template getContent =
|
||||||
resp = await c.get($url)
|
resp = await c.get($url)
|
||||||
result = await resp.body
|
result = await resp.body
|
||||||
|
|
||||||
getContent()
|
getContent()
|
||||||
|
|
||||||
|
if resp.status == $Http429:
|
||||||
|
raise rateLimitError()
|
||||||
|
|
||||||
if resp.status == $Http503:
|
if resp.status == $Http503:
|
||||||
badClient = true
|
badClient = true
|
||||||
raise newException(BadClientError, "Bad client")
|
raise newException(BadClientError, "Bad client")
|
||||||
|
@ -133,10 +147,11 @@ template retry(bod) =
|
||||||
echo "[accounts] Rate limited, retrying ", api, " request..."
|
echo "[accounts] Rate limited, retrying ", api, " request..."
|
||||||
bod
|
bod
|
||||||
|
|
||||||
proc fetch*(url: Uri; api: Api): Future[JsonNode] {.async.} =
|
proc fetch*(url: Uri; api: Api; additional_headers: HttpHeaders = newHttpHeaders()): Future[JsonNode] {.async.} =
|
||||||
|
|
||||||
retry:
|
retry:
|
||||||
var body: string
|
var body: string
|
||||||
fetchImpl body:
|
fetchImpl(body, additional_headers):
|
||||||
if body.startsWith('{') or body.startsWith('['):
|
if body.startsWith('{') or body.startsWith('['):
|
||||||
result = parseJson(body)
|
result = parseJson(body)
|
||||||
else:
|
else:
|
||||||
|
@ -149,9 +164,9 @@ proc fetch*(url: Uri; api: Api): Future[JsonNode] {.async.} =
|
||||||
invalidate(account)
|
invalidate(account)
|
||||||
raise rateLimitError()
|
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:
|
retry:
|
||||||
fetchImpl result:
|
fetchImpl(result, additional_headers):
|
||||||
if not (result.startsWith('{') or result.startsWith('[')):
|
if not (result.startsWith('{') or result.startsWith('[')):
|
||||||
echo resp.status, ": ", result, " --- url: ", url
|
echo resp.status, ": ", result, " --- url: ", url
|
||||||
result.setLen(0)
|
result.setLen(0)
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import parsecfg except Config
|
import parsecfg except Config
|
||||||
import types, strutils
|
import types, strutils
|
||||||
|
from os import getEnv
|
||||||
|
|
||||||
proc get*[T](config: parseCfg.Config; section, key: string; default: T): T =
|
proc get*[T](config: parseCfg.Config; section, key: string; default: T): T =
|
||||||
let val = config.getSectionValue(section, key)
|
let val = config.getSectionValue(section, key)
|
||||||
|
@ -44,3 +45,7 @@ proc getConfig*(path: string): (Config, parseCfg.Config) =
|
||||||
)
|
)
|
||||||
|
|
||||||
return (conf, cfg)
|
return (conf, cfg)
|
||||||
|
|
||||||
|
|
||||||
|
let configPath = getEnv("NITTER_CONF_FILE", "./nitter.conf")
|
||||||
|
let (cfg*, fullCfg*) = getConfig(configPath)
|
||||||
|
|
|
@ -10,6 +10,8 @@ const
|
||||||
|
|
||||||
photoRail* = api / "1.1/statuses/media_timeline.json"
|
photoRail* = api / "1.1/statuses/media_timeline.json"
|
||||||
|
|
||||||
|
timelineApi = api / "2/timeline"
|
||||||
|
|
||||||
graphql = api / "graphql"
|
graphql = api / "graphql"
|
||||||
graphUser* = graphql / "u7wQyGi6oExe8_TRWGMq4Q/UserResultByScreenNameQuery"
|
graphUser* = graphql / "u7wQyGi6oExe8_TRWGMq4Q/UserResultByScreenNameQuery"
|
||||||
graphUserById* = graphql / "oPppcargziU1uDQHAUmH-A/UserResultByIdQuery"
|
graphUserById* = graphql / "oPppcargziU1uDQHAUmH-A/UserResultByIdQuery"
|
||||||
|
@ -23,6 +25,11 @@ const
|
||||||
graphListBySlug* = graphql / "-kmqNvm5Y-cVrfvBy6docg/ListBySlug"
|
graphListBySlug* = graphql / "-kmqNvm5Y-cVrfvBy6docg/ListBySlug"
|
||||||
graphListMembers* = graphql / "P4NpVZDqUD_7MEM84L-8nw/ListMembers"
|
graphListMembers* = graphql / "P4NpVZDqUD_7MEM84L-8nw/ListMembers"
|
||||||
graphListTweets* = graphql / "BbGLL1ZfMibdFNWlk7a0Pw/ListTimeline"
|
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* = {
|
timelineParams* = {
|
||||||
"include_can_media_tag": "1",
|
"include_can_media_tag": "1",
|
||||||
|
@ -43,6 +50,7 @@ const
|
||||||
gqlFeatures* = """{
|
gqlFeatures* = """{
|
||||||
"android_graphql_skip_api_media_color_palette": false,
|
"android_graphql_skip_api_media_color_palette": false,
|
||||||
"blue_business_profile_image_shape_enabled": false,
|
"blue_business_profile_image_shape_enabled": false,
|
||||||
|
"c9s_tweet_anatomy_moderator_badge_enabled": false,
|
||||||
"creator_subscriptions_subscription_count_enabled": false,
|
"creator_subscriptions_subscription_count_enabled": false,
|
||||||
"creator_subscriptions_tweet_preview_api_enabled": true,
|
"creator_subscriptions_tweet_preview_api_enabled": true,
|
||||||
"freedom_of_speech_not_reach_fetch_enabled": false,
|
"freedom_of_speech_not_reach_fetch_enabled": false,
|
||||||
|
@ -64,6 +72,7 @@ const
|
||||||
"responsive_web_twitter_article_tweet_consumption_enabled": false,
|
"responsive_web_twitter_article_tweet_consumption_enabled": false,
|
||||||
"responsive_web_twitter_blue_verified_badge_is_enabled": true,
|
"responsive_web_twitter_blue_verified_badge_is_enabled": true,
|
||||||
"rweb_lists_timeline_redesign_enabled": true,
|
"rweb_lists_timeline_redesign_enabled": true,
|
||||||
|
"rweb_video_timestamps_enabled": true,
|
||||||
"spaces_2022_h2_clipping": true,
|
"spaces_2022_h2_clipping": true,
|
||||||
"spaces_2022_h2_spaces_communities": true,
|
"spaces_2022_h2_spaces_communities": true,
|
||||||
"standardized_nudges_misinfo": false,
|
"standardized_nudges_misinfo": false,
|
||||||
|
@ -114,3 +123,15 @@ const
|
||||||
"rest_id": "$1", $2
|
"rest_id": "$1", $2
|
||||||
"count": 20
|
"count": 20
|
||||||
}"""
|
}"""
|
||||||
|
|
||||||
|
reactorsVariables* = """{
|
||||||
|
"tweetId" : "$1", $2
|
||||||
|
"count" : 20,
|
||||||
|
"includePromotedContent": false
|
||||||
|
}"""
|
||||||
|
|
||||||
|
followVariables* = """{
|
||||||
|
"userId" : "$1", $2
|
||||||
|
"count" : 20,
|
||||||
|
"includePromotedContent": false
|
||||||
|
}"""
|
||||||
|
|
|
@ -82,8 +82,6 @@ proc proxifyVideo*(manifest: string; proxy: bool): string =
|
||||||
for line in manifest.splitLines:
|
for line in manifest.splitLines:
|
||||||
let url =
|
let url =
|
||||||
if line.startsWith("#EXT-X-MAP:URI"): line[16 .. ^2]
|
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
|
else: line
|
||||||
if url.startsWith('/'):
|
if url.startsWith('/'):
|
||||||
let path = "https://video.twimg.com" & url
|
let path = "https://video.twimg.com" & url
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import asyncdispatch, strformat, logging
|
import asyncdispatch, strformat, logging
|
||||||
|
import config
|
||||||
from net import Port
|
from net import Port
|
||||||
from htmlgen import a
|
from htmlgen import a
|
||||||
from os import getEnv
|
from os import getEnv
|
||||||
|
@ -10,15 +11,12 @@ import types, config, prefs, formatters, redis_cache, http_pool, auth
|
||||||
import views/[general, about]
|
import views/[general, about]
|
||||||
import routes/[
|
import routes/[
|
||||||
preferences, timeline, status, media, search, rss, list, debug,
|
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 instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
|
||||||
const issuesUrl = "https://github.com/zedeus/nitter/issues"
|
const issuesUrl = "https://github.com/zedeus/nitter/issues"
|
||||||
|
|
||||||
let
|
let
|
||||||
configPath = getEnv("NITTER_CONF_FILE", "./nitter.conf")
|
|
||||||
(cfg, fullCfg) = getConfig(configPath)
|
|
||||||
|
|
||||||
accountsPath = getEnv("NITTER_ACCOUNTS_FILE", "./guest_accounts.json")
|
accountsPath = getEnv("NITTER_ACCOUNTS_FILE", "./guest_accounts.json")
|
||||||
|
|
||||||
initAccountPool(cfg, accountsPath)
|
initAccountPool(cfg, accountsPath)
|
||||||
|
@ -53,7 +51,6 @@ createSearchRouter(cfg)
|
||||||
createMediaRouter(cfg)
|
createMediaRouter(cfg)
|
||||||
createEmbedRouter(cfg)
|
createEmbedRouter(cfg)
|
||||||
createRssRouter(cfg)
|
createRssRouter(cfg)
|
||||||
createTwitterApiRouter(cfg)
|
|
||||||
createDebugRouter(cfg)
|
createDebugRouter(cfg)
|
||||||
|
|
||||||
settings:
|
settings:
|
||||||
|
|
174
src/parser.nim
174
src/parser.nim
|
@ -3,6 +3,7 @@ import strutils, options, times, math
|
||||||
import packedjson, packedjson/deserialiser
|
import packedjson, packedjson/deserialiser
|
||||||
import types, parserutils, utils
|
import types, parserutils, utils
|
||||||
import experimental/parser/unifiedcard
|
import experimental/parser/unifiedcard
|
||||||
|
import std/tables
|
||||||
|
|
||||||
proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet
|
proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet
|
||||||
|
|
||||||
|
@ -32,7 +33,8 @@ proc parseGraphUser(js: JsonNode): User =
|
||||||
var user = js{"user_result", "result"}
|
var user = js{"user_result", "result"}
|
||||||
if user.isNull:
|
if user.isNull:
|
||||||
user = ? js{"user_results", "result"}
|
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):
|
if result.verifiedType == VerifiedType.none and user{"is_blue_verified"}.getBool(false):
|
||||||
result.verifiedType = blue
|
result.verifiedType = blue
|
||||||
|
@ -236,8 +238,11 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
||||||
# graphql
|
# graphql
|
||||||
with rt, js{"retweeted_status_result", "result"}:
|
with rt, js{"retweeted_status_result", "result"}:
|
||||||
# needed due to weird edgecase where the actual tweet data isn't included
|
# needed due to weird edgecase where the actual tweet data isn't included
|
||||||
if "legacy" in rt:
|
var rt_tweet = rt
|
||||||
result.retweet = some parseGraphTweet(rt)
|
if "tweet" in rt:
|
||||||
|
rt_tweet = rt{"tweet"}
|
||||||
|
if "legacy" in rt_tweet:
|
||||||
|
result.retweet = some parseGraphTweet(rt_tweet)
|
||||||
return
|
return
|
||||||
|
|
||||||
if jsCard.kind != JNull:
|
if jsCard.kind != JNull:
|
||||||
|
@ -289,6 +294,121 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
||||||
result.text.removeSuffix(" Learn more.")
|
result.text.removeSuffix(" Learn more.")
|
||||||
result.available = false
|
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 =
|
proc parsePhotoRail*(js: JsonNode): PhotoRail =
|
||||||
with error, js{"error"}:
|
with error, js{"error"}:
|
||||||
if error.getStr == "Not authorized.":
|
if error.getStr == "Not authorized.":
|
||||||
|
@ -415,7 +535,8 @@ proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile =
|
||||||
|
|
||||||
let instructions =
|
let instructions =
|
||||||
if root == "list": ? js{"data", "list", "timeline_response", "timeline", "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:
|
if instructions.len == 0:
|
||||||
return
|
return
|
||||||
|
@ -435,6 +556,21 @@ proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile =
|
||||||
result.tweets.content.add thread.content
|
result.tweets.content.add thread.content
|
||||||
elif entryId.startsWith("cursor-bottom"):
|
elif entryId.startsWith("cursor-bottom"):
|
||||||
result.tweets.bottom = e{"content", "value"}.getStr
|
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":
|
if after.len == 0 and i{"__typename"}.getStr == "TimelinePinEntry":
|
||||||
with tweetResult, i{"entry", "content", "content", "tweetResult", "result"}:
|
with tweetResult, i{"entry", "content", "content", "tweetResult", "result"}:
|
||||||
let tweet = parseGraphTweet(tweetResult, false)
|
let tweet = parseGraphTweet(tweetResult, false)
|
||||||
|
@ -445,6 +581,36 @@ proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile =
|
||||||
tweet.id = parseBiggestInt(entryId)
|
tweet.id = parseBiggestInt(entryId)
|
||||||
result.pinned = some tweet
|
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] =
|
proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] =
|
||||||
result = Result[T](beginning: after.len == 0)
|
result = Result[T](beginning: after.len == 0)
|
||||||
|
|
||||||
|
|
|
@ -40,6 +40,13 @@ proc getMediaQuery*(name: string): Query =
|
||||||
sep: "OR"
|
sep: "OR"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
proc getFavoritesQuery*(name: string): Query =
|
||||||
|
Query(
|
||||||
|
kind: favorites,
|
||||||
|
fromUser: @[name]
|
||||||
|
)
|
||||||
|
|
||||||
proc getReplyQuery*(name: string): Query =
|
proc getReplyQuery*(name: string): Query =
|
||||||
Query(
|
Query(
|
||||||
kind: replies,
|
kind: replies,
|
||||||
|
|
|
@ -23,7 +23,7 @@ proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async.
|
||||||
names = getNames(name)
|
names = getNames(name)
|
||||||
|
|
||||||
if names.len == 1:
|
if names.len == 1:
|
||||||
profile = await fetchProfile(after, query, skipRail=true, skipPinned=true)
|
profile = await fetchProfile(after, query, cfg, skipRail=true, skipPinned=true)
|
||||||
else:
|
else:
|
||||||
var q = query
|
var q = query
|
||||||
q.fromUser = names
|
q.fromUser = names
|
||||||
|
@ -102,7 +102,7 @@ proc createRssRouter*(cfg: Config) =
|
||||||
get "/@name/@tab/rss":
|
get "/@name/@tab/rss":
|
||||||
cond cfg.enableRss
|
cond cfg.enableRss
|
||||||
cond '.' notin @"name"
|
cond '.' notin @"name"
|
||||||
cond @"tab" in ["with_replies", "media", "search"]
|
cond @"tab" in ["with_replies", "media", "favorites", "search"]
|
||||||
let
|
let
|
||||||
name = @"name"
|
name = @"name"
|
||||||
tab = @"tab"
|
tab = @"tab"
|
||||||
|
@ -110,6 +110,7 @@ proc createRssRouter*(cfg: Config) =
|
||||||
case tab
|
case tab
|
||||||
of "with_replies": getReplyQuery(name)
|
of "with_replies": getReplyQuery(name)
|
||||||
of "media": getMediaQuery(name)
|
of "media": getMediaQuery(name)
|
||||||
|
of "favorites": getFavoritesQuery(name)
|
||||||
of "search": initQuery(params(request), name=name)
|
of "search": initQuery(params(request), name=name)
|
||||||
else: Query(fromUser: @[name])
|
else: Query(fromUser: @[name])
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@ import jester, karax/vdom
|
||||||
|
|
||||||
import router_utils
|
import router_utils
|
||||||
import ".."/[types, formatters, api]
|
import ".."/[types, formatters, api]
|
||||||
import ../views/[general, status]
|
import ../views/[general, status, search]
|
||||||
|
|
||||||
export uri, sequtils, options, sugar
|
export uri, sequtils, options, sugar
|
||||||
export router_utils
|
export router_utils
|
||||||
|
@ -14,6 +14,29 @@ export status
|
||||||
|
|
||||||
proc createStatusRouter*(cfg: Config) =
|
proc createStatusRouter*(cfg: Config) =
|
||||||
router status:
|
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/?":
|
get "/@name/status/@id/?":
|
||||||
cond '.' notin @"name"
|
cond '.' notin @"name"
|
||||||
let id = @"id"
|
let id = @"id"
|
||||||
|
|
|
@ -16,6 +16,7 @@ proc getQuery*(request: Request; tab, name: string): Query =
|
||||||
case tab
|
case tab
|
||||||
of "with_replies": getReplyQuery(name)
|
of "with_replies": getReplyQuery(name)
|
||||||
of "media": getMediaQuery(name)
|
of "media": getMediaQuery(name)
|
||||||
|
of "favorites": getFavoritesQuery(name)
|
||||||
of "search": initQuery(params(request), name=name)
|
of "search": initQuery(params(request), name=name)
|
||||||
else: Query(fromUser: @[name])
|
else: Query(fromUser: @[name])
|
||||||
|
|
||||||
|
@ -27,7 +28,7 @@ template skipIf[T](cond: bool; default; body: Future[T]): Future[T] =
|
||||||
else:
|
else:
|
||||||
body
|
body
|
||||||
|
|
||||||
proc fetchProfile*(after: string; query: Query; skipRail=false;
|
proc fetchProfile*(after: string; query: Query; cfg: Config; skipRail=false;
|
||||||
skipPinned=false): Future[Profile] {.async.} =
|
skipPinned=false): Future[Profile] {.async.} =
|
||||||
let
|
let
|
||||||
name = query.fromUser[0]
|
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 posts: await getGraphUserTweets(userId, TimelineKind.tweets, after)
|
||||||
of replies: await getGraphUserTweets(userId, TimelineKind.replies, after)
|
of replies: await getGraphUserTweets(userId, TimelineKind.replies, after)
|
||||||
of media: await getGraphUserTweets(userId, TimelineKind.media, after)
|
of media: await getGraphUserTweets(userId, TimelineKind.media, after)
|
||||||
|
of favorites: await getFavorites(userId, cfg, after)
|
||||||
else: Profile(tweets: await getGraphTweetSearch(query, after))
|
else: Profile(tweets: await getGraphTweetSearch(query, after))
|
||||||
|
|
||||||
result.user = await user
|
result.user = await user
|
||||||
|
@ -71,7 +73,7 @@ proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs;
|
||||||
html = renderTweetSearch(timeline, prefs, getPath())
|
html = renderTweetSearch(timeline, prefs, getPath())
|
||||||
return renderMain(html, request, cfg, prefs, "Multi", rss=rss)
|
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
|
template u: untyped = profile.user
|
||||||
|
|
||||||
if u.suspended:
|
if u.suspended:
|
||||||
|
@ -79,7 +81,7 @@ proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs;
|
||||||
|
|
||||||
if profile.user.id.len == 0: return
|
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),
|
result = renderMain(pHtml, request, cfg, prefs, pageTitle(u), pageDesc(u),
|
||||||
rss=rss, images = @[u.getUserPic("_400x400")],
|
rss=rss, images = @[u.getUserPic("_400x400")],
|
||||||
banner=u.banner)
|
banner=u.banner)
|
||||||
|
@ -109,12 +111,19 @@ proc createTimelineRouter*(cfg: Config) =
|
||||||
get "/@name/?@tab?/?":
|
get "/@name/?@tab?/?":
|
||||||
cond '.' notin @"name"
|
cond '.' notin @"name"
|
||||||
cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"]
|
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
|
let
|
||||||
prefs = cookiePrefs()
|
prefs = cookiePrefs()
|
||||||
after = getCursor()
|
after = getCursor()
|
||||||
names = getNames(@"name")
|
names = getNames(@"name")
|
||||||
|
tab = @"tab"
|
||||||
|
|
||||||
|
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 query = request.getQuery(@"tab", @"name")
|
var query = request.getQuery(@"tab", @"name")
|
||||||
if names.len != 1:
|
if names.len != 1:
|
||||||
query.fromUser = names
|
query.fromUser = names
|
||||||
|
@ -127,7 +136,7 @@ proc createTimelineRouter*(cfg: Config) =
|
||||||
timeline.beginning = true
|
timeline.beginning = true
|
||||||
resp $renderTweetSearch(timeline, prefs, getPath())
|
resp $renderTweetSearch(timeline, prefs, getPath())
|
||||||
else:
|
else:
|
||||||
var profile = await fetchProfile(after, query, skipRail=true)
|
var profile = await fetchProfile(after, query, cfg, skipRail=true)
|
||||||
if profile.tweets.content.len == 0: resp Http404
|
if profile.tweets.content.len == 0: resp Http404
|
||||||
profile.tweets.beginning = true
|
profile.tweets.beginning = true
|
||||||
resp $renderTimelineTweets(profile.tweets, prefs, getPath())
|
resp $renderTimelineTweets(profile.tweets, prefs, getPath())
|
||||||
|
|
|
@ -207,6 +207,7 @@
|
||||||
padding-top: 5px;
|
padding-top: 5px;
|
||||||
min-width: 1em;
|
min-width: 1em;
|
||||||
margin-right: 0.8em;
|
margin-right: 0.8em;
|
||||||
|
pointer-events: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.show-thread {
|
.show-thread {
|
||||||
|
|
|
@ -23,9 +23,14 @@ type
|
||||||
listTweets
|
listTweets
|
||||||
userRestId
|
userRestId
|
||||||
userScreenName
|
userScreenName
|
||||||
|
favorites
|
||||||
userTweets
|
userTweets
|
||||||
userTweetsAndReplies
|
userTweetsAndReplies
|
||||||
userMedia
|
userMedia
|
||||||
|
favoriters
|
||||||
|
retweeters
|
||||||
|
following
|
||||||
|
followers
|
||||||
|
|
||||||
RateLimit* = object
|
RateLimit* = object
|
||||||
remaining*: int
|
remaining*: int
|
||||||
|
@ -111,7 +116,7 @@ type
|
||||||
variants*: seq[VideoVariant]
|
variants*: seq[VideoVariant]
|
||||||
|
|
||||||
QueryKind* = enum
|
QueryKind* = enum
|
||||||
posts, replies, media, users, tweets, userList
|
posts, replies, media, users, tweets, userList, favorites
|
||||||
|
|
||||||
Query* = object
|
Query* = object
|
||||||
kind*: QueryKind
|
kind*: QueryKind
|
||||||
|
@ -231,6 +236,7 @@ type
|
||||||
replies*: Result[Chain]
|
replies*: Result[Chain]
|
||||||
|
|
||||||
Timeline* = Result[Tweets]
|
Timeline* = Result[Tweets]
|
||||||
|
UsersTimeline* = Result[User]
|
||||||
|
|
||||||
Profile* = object
|
Profile* = object
|
||||||
user*: User
|
user*: User
|
||||||
|
@ -276,6 +282,7 @@ type
|
||||||
redisConns*: int
|
redisConns*: int
|
||||||
redisMaxConns*: int
|
redisMaxConns*: int
|
||||||
redisPassword*: string
|
redisPassword*: string
|
||||||
|
redisDb*: int
|
||||||
|
|
||||||
Rss* = object
|
Rss* = object
|
||||||
feed*, cursor*: string
|
feed*, cursor*: string
|
||||||
|
|
|
@ -32,6 +32,8 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode =
|
||||||
if cfg.enableRss and rss.len > 0:
|
if cfg.enableRss and rss.len > 0:
|
||||||
icon "rss-feed", title="RSS Feed", href=rss
|
icon "rss-feed", title="RSS Feed", href=rss
|
||||||
icon "bird", title="Open in Twitter", href=canonical
|
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))
|
icon "cog", title="Preferences", href=("/settings?referer=" & encodeUrl(path))
|
||||||
|
|
||||||
proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
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")
|
link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed")
|
||||||
|
|
||||||
if prefs.hlsPlayback:
|
if prefs.hlsPlayback:
|
||||||
script(src="/js/hls.min.js", `defer`="")
|
script(src="/js/hls.light.min.js", `defer`="")
|
||||||
script(src="/js/hlsPlayback.js", `defer`="")
|
script(src="/js/hlsPlayback.js", `defer`="")
|
||||||
|
|
||||||
if prefs.infiniteScroll:
|
if prefs.infiniteScroll:
|
||||||
|
|
|
@ -13,7 +13,7 @@ proc renderStat(num: int; class: string; text=""): VNode =
|
||||||
text insertSep($num, ',')
|
text insertSep($num, ',')
|
||||||
|
|
||||||
proc renderUserCard*(user: User; prefs: Prefs): VNode =
|
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"):
|
tdiv(class="profile-card-info"):
|
||||||
let
|
let
|
||||||
url = getPicUrl(user.getUserPic())
|
url = getPicUrl(user.getUserPic())
|
||||||
|
@ -58,9 +58,13 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
|
||||||
|
|
||||||
tdiv(class="profile-card-extra-links"):
|
tdiv(class="profile-card-extra-links"):
|
||||||
ul(class="profile-statlist"):
|
ul(class="profile-statlist"):
|
||||||
|
a(href="/" & user.username):
|
||||||
renderStat(user.tweets, "posts", text="Tweets")
|
renderStat(user.tweets, "posts", text="Tweets")
|
||||||
|
a(href="/" & user.username & "/following"):
|
||||||
renderStat(user.following, "following")
|
renderStat(user.following, "following")
|
||||||
|
a(href="/" & user.username & "/followers"):
|
||||||
renderStat(user.followers, "followers")
|
renderStat(user.followers, "followers")
|
||||||
|
a(href="/" & user.username & "/favorites"):
|
||||||
renderStat(user.likes, "likes")
|
renderStat(user.likes, "likes")
|
||||||
|
|
||||||
proc renderPhotoRail(profile: Profile): VNode =
|
proc renderPhotoRail(profile: Profile): VNode =
|
||||||
|
@ -99,7 +103,7 @@ proc renderProtected(username: string): VNode =
|
||||||
h2: text "This account's tweets are protected."
|
h2: text "This account's tweets are protected."
|
||||||
p: text &"Only confirmed followers have access to @{username}'s tweets."
|
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]
|
profile.tweets.query.fromUser = @[profile.user.username]
|
||||||
|
|
||||||
buildHtml(tdiv(class="profile-tabs")):
|
buildHtml(tdiv(class="profile-tabs")):
|
||||||
|
|
|
@ -3,7 +3,7 @@ import strutils, strformat, sequtils, unicode, tables, options
|
||||||
import karax/[karaxdsl, vdom]
|
import karax/[karaxdsl, vdom]
|
||||||
|
|
||||||
import renderutils, timeline
|
import renderutils, timeline
|
||||||
import ".."/[types, query]
|
import ".."/[types, query, config]
|
||||||
|
|
||||||
const toggles = {
|
const toggles = {
|
||||||
"nativeretweets": "Retweets",
|
"nativeretweets": "Retweets",
|
||||||
|
@ -29,7 +29,7 @@ proc renderSearch*(): VNode =
|
||||||
placeholder="Enter username...", dir="auto")
|
placeholder="Enter username...", dir="auto")
|
||||||
button(`type`="submit"): icon "search"
|
button(`type`="submit"): icon "search"
|
||||||
|
|
||||||
proc renderProfileTabs*(query: Query; username: string): VNode =
|
proc renderProfileTabs*(query: Query; username: string; cfg: Config): VNode =
|
||||||
let link = "/" & username
|
let link = "/" & username
|
||||||
buildHtml(ul(class="tab")):
|
buildHtml(ul(class="tab")):
|
||||||
li(class=query.getTabClass(posts)):
|
li(class=query.getTabClass(posts)):
|
||||||
|
@ -38,6 +38,8 @@ proc renderProfileTabs*(query: Query; username: string): VNode =
|
||||||
a(href=(link & "/with_replies")): text "Tweets & Replies"
|
a(href=(link & "/with_replies")): text "Tweets & Replies"
|
||||||
li(class=query.getTabClass(media)):
|
li(class=query.getTabClass(media)):
|
||||||
a(href=(link & "/media")): text "Media"
|
a(href=(link & "/media")): text "Media"
|
||||||
|
li(class=query.getTabClass(favorites)):
|
||||||
|
a(href=(link & "/favorites")): text "Likes"
|
||||||
li(class=query.getTabClass(tweets)):
|
li(class=query.getTabClass(tweets)):
|
||||||
a(href=(link & "/search")): text "Search"
|
a(href=(link & "/search")): text "Search"
|
||||||
|
|
||||||
|
@ -97,7 +99,7 @@ proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string;
|
||||||
text query.fromUser.join(" | ")
|
text query.fromUser.join(" | ")
|
||||||
|
|
||||||
if query.fromUser.len > 0:
|
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:
|
if query.fromUser.len == 0 or query.kind == tweets:
|
||||||
tdiv(class="timeline-header"):
|
tdiv(class="timeline-header"):
|
||||||
|
@ -118,3 +120,8 @@ proc renderUserSearch*(results: Result[User]; prefs: Prefs): VNode =
|
||||||
|
|
||||||
renderSearchTabs(results.query)
|
renderSearchTabs(results.query)
|
||||||
renderTimelineUsers(results, prefs)
|
renderTimelineUsers(results, prefs)
|
||||||
|
|
||||||
|
proc renderUserList*(results: Result[User]; prefs: Prefs): VNode =
|
||||||
|
buildHtml(tdiv(class="timeline-container")):
|
||||||
|
tdiv(class="timeline-header")
|
||||||
|
renderTimelineUsers(results, prefs)
|
||||||
|
|
|
@ -180,12 +180,17 @@ func formatStat(stat: int): string =
|
||||||
if stat > 0: insertSep($stat, ',')
|
if stat > 0: insertSep($stat, ',')
|
||||||
else: ""
|
else: ""
|
||||||
|
|
||||||
proc renderStats(stats: TweetStats; views: string): VNode =
|
proc renderStats(stats: TweetStats; views: string; tweet: Tweet): VNode =
|
||||||
buildHtml(tdiv(class="tweet-stats")):
|
buildHtml(tdiv(class="tweet-stats")):
|
||||||
|
a(href=getLink(tweet)):
|
||||||
span(class="tweet-stat"): icon "comment", formatStat(stats.replies)
|
span(class="tweet-stat"): icon "comment", formatStat(stats.replies)
|
||||||
|
a(href=getLink(tweet, false) & "/retweeters"):
|
||||||
span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets)
|
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)
|
span(class="tweet-stat"): icon "quote", formatStat(stats.quotes)
|
||||||
|
a(href=getLink(tweet, false) & "/favoriters"):
|
||||||
span(class="tweet-stat"): icon "heart", formatStat(stats.likes)
|
span(class="tweet-stat"): icon "heart", formatStat(stats.likes)
|
||||||
|
a(href=getLink(tweet)):
|
||||||
if views.len > 0:
|
if views.len > 0:
|
||||||
span(class="tweet-stat"): icon "play", insertSep(views, ',')
|
span(class="tweet-stat"): icon "play", insertSep(views, ',')
|
||||||
|
|
||||||
|
@ -345,7 +350,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
||||||
renderMediaTags(tweet.mediaTags)
|
renderMediaTags(tweet.mediaTags)
|
||||||
|
|
||||||
if not prefs.hideTweetStats:
|
if not prefs.hideTweetStats:
|
||||||
renderStats(tweet.stats, views)
|
renderStats(tweet.stats, views, tweet)
|
||||||
|
|
||||||
if showThread:
|
if showThread:
|
||||||
a(class="show-thread", href=("/i/status/" & $tweet.threadId)):
|
a(class="show-thread", href=("/i/status/" & $tweet.threadId)):
|
||||||
|
|
Loading…
Reference in a new issue