Просмотр исходного кода

Fix everything (#927)

* Switch bearer token and endpoints, update parser

* Enable user search, disable tweet search

* Disable multi-user timelines for now

* Fix parsing of pinned tombstone
Zed 3 лет назад
Родитель
Сommit
0bc3c153d9

+ 13 - 13
src/api.nim

@@ -7,20 +7,20 @@ import experimental/parser as newParser
 proc getGraphUser*(username: string): Future[User] {.async.} =
 proc getGraphUser*(username: string): Future[User] {.async.} =
   if username.len == 0: return
   if username.len == 0: return
   let
   let
-    variables = %*{"screen_name": username}
-    params = {"variables": $variables, "features": gqlFeatures}
+    variables = """{"screen_name": "$1"}""" % username
+    params = {"variables": variables, "features": gqlFeatures}
     js = await fetchRaw(graphUser ? params, Api.userScreenName)
     js = await fetchRaw(graphUser ? params, Api.userScreenName)
   result = parseGraphUser(js)
   result = parseGraphUser(js)
 
 
 proc getGraphUserById*(id: string): Future[User] {.async.} =
 proc getGraphUserById*(id: string): Future[User] {.async.} =
   if id.len == 0 or id.any(c => not c.isDigit): return
   if id.len == 0 or id.any(c => not c.isDigit): return
   let
   let
-    variables = %*{"userId": id}
-    params = {"variables": $variables, "features": gqlFeatures}
+    variables = """{"rest_id": "$1"}""" % id
+    params = {"variables": variables, "features": gqlFeatures}
     js = await fetchRaw(graphUserById ? params, Api.userRestId)
     js = await fetchRaw(graphUserById ? params, Api.userRestId)
   result = parseGraphUser(js)
   result = parseGraphUser(js)
 
 
-proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Timeline] {.async.} =
+proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} =
   if id.len == 0: return
   if id.len == 0: return
   let
   let
     cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
     cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
@@ -40,7 +40,7 @@ proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} =
     variables = listTweetsVariables % [id, cursor]
     variables = listTweetsVariables % [id, cursor]
     params = {"variables": variables, "features": gqlFeatures}
     params = {"variables": variables, "features": gqlFeatures}
     js = await fetch(graphListTweets ? params, Api.listTweets)
     js = await fetch(graphListTweets ? params, Api.listTweets)
-  result = parseGraphTimeline(js, "list", after)
+  result = parseGraphTimeline(js, "list", after).tweets
 
 
 proc getGraphListBySlug*(name, list: string): Future[List] {.async.} =
 proc getGraphListBySlug*(name, list: string): Future[List] {.async.} =
   let
   let
@@ -50,8 +50,8 @@ proc getGraphListBySlug*(name, list: string): Future[List] {.async.} =
 
 
 proc getGraphList*(id: string): Future[List] {.async.} =
 proc getGraphList*(id: string): Future[List] {.async.} =
   let
   let
-    variables = %*{"listId": id}
-    params = {"variables": $variables, "features": gqlFeatures}
+    variables = """{"listId": "$1"}""" % id
+    params = {"variables": variables, "features": gqlFeatures}
   result = parseGraphList(await fetch(graphListById ? params, Api.list))
   result = parseGraphList(await fetch(graphListById ? params, Api.list))
 
 
 proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} =
 proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} =
@@ -72,7 +72,7 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.}
 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
-    variables = tweetResultVariables % id
+    variables = """{"rest_id": "$1"}""" % id
     params = {"variables": variables, "features": gqlFeatures}
     params = {"variables": variables, "features": gqlFeatures}
     js = await fetch(graphTweetResult ? params, Api.tweetResult)
     js = await fetch(graphTweetResult ? params, Api.tweetResult)
   result = parseGraphTweetResult(js)
   result = parseGraphTweetResult(js)
@@ -95,10 +95,10 @@ proc getTweet*(id: string; after=""): Future[Conversation] {.async.} =
   if after.len > 0:
   if after.len > 0:
     result.replies = await getReplies(id, after)
     result.replies = await getReplies(id, after)
 
 
-proc getGraphSearch*(query: Query; after=""): Future[Result[Tweet]] {.async.} =
+proc getGraphSearch*(query: Query; after=""): Future[Profile] {.async.} =
   let q = genQueryParam(query)
   let q = genQueryParam(query)
   if q.len == 0 or q == emptyQuery:
   if q.len == 0 or q == emptyQuery:
-    return Result[Tweet](query: query, beginning: true)
+    return Profile(tweets: Timeline(query: query, beginning: true))
 
 
   var
   var
     variables = %*{
     variables = %*{
@@ -112,8 +112,8 @@ proc getGraphSearch*(query: Query; after=""): Future[Result[Tweet]] {.async.} =
   if after.len > 0:
   if after.len > 0:
     variables["cursor"] = % after
     variables["cursor"] = % after
   let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures}
   let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures}
-  result = parseGraphSearch(await fetch(url, Api.search), after)
-  result.query = query
+  result = Profile(tweets: parseGraphSearch(await fetch(url, Api.search), after))
+  result.tweets.query = query
 
 
 proc getUserSearch*(query: Query; page="1"): Future[Result[User]] {.async.} =
 proc getUserSearch*(query: Query; page="1"): Future[Result[User]] {.async.} =
   if query.text.len == 0:
   if query.text.len == 0:

+ 27 - 40
src/consts.nim

@@ -2,7 +2,7 @@
 import uri, sequtils, strutils
 import uri, sequtils, strutils
 
 
 const
 const
-  auth* = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
+  auth* = "Bearer AAAAAAAAAAAAAAAAAAAAAFQODgEAAAAAVHTp76lzh3rFzcHbmHVvQxYYpTw%3DckAlMINMjmCwxUcaXbAN4XqJVdgMJaHqNOFgPMK0zN1qLqLQCF"
 
 
   api = parseUri("https://api.twitter.com")
   api = parseUri("https://api.twitter.com")
   activate* = $(api / "1.1/guest/activate.json")
   activate* = $(api / "1.1/guest/activate.json")
@@ -11,18 +11,18 @@ const
   userSearch* = api / "1.1/users/search.json"
   userSearch* = api / "1.1/users/search.json"
 
 
   graphql = api / "graphql"
   graphql = api / "graphql"
-  graphUser* = graphql / "pVrmNaXcxPjisIvKtLDMEA/UserByScreenName"
-  graphUserById* = graphql / "1YAM811Q8Ry4XyPpJclURQ/UserByRestId"
-  graphUserTweets* = graphql / "WzJjibAcDa-oCjCcLOotcg/UserTweets"
-  graphUserTweetsAndReplies* = graphql / "fn9oRltM1N4thkh5CVusPg/UserTweetsAndReplies"
-  graphUserMedia* = graphql / "qQoeS7szGavsi8-ehD2AWg/UserMedia"
-  graphTweet* = graphql / "miKSMGb2R1SewIJv2-ablQ/TweetDetail"
-  graphTweetResult* = graphql / "0kc0a_7TTr3dvweZlMslsQ/TweetResultByRestId"
+  graphUser* = graphql / "u7wQyGi6oExe8_TRWGMq4Q/UserResultByScreenNameQuery"
+  graphUserById* = graphql / "oPppcargziU1uDQHAUmH-A/UserResultByIdQuery"
+  graphUserTweets* = graphql / "3JNH4e9dq1BifLxAa3UMWg/UserWithProfileTweetsQueryV2"
+  graphUserTweetsAndReplies* = graphql / "8IS8MaO-2EN6GZZZb8jF0g/UserWithProfileTweetsAndRepliesQueryV2"
+  graphUserMedia* = graphql / "PDfFf8hGeJvUCiTyWtw4wQ/MediaTimelineV2"
+  graphTweet* = graphql / "83h5UyHZ9wEKBVzALX8R_g/ConversationTimelineV2"
+  graphTweetResult* = graphql / "sITyJdhRPpvpEjg4waUmTA/TweetResultByIdQuery"
   graphSearchTimeline* = graphql / "gkjsKepM6gl_HmFWoWKfgg/SearchTimeline"
   graphSearchTimeline* = graphql / "gkjsKepM6gl_HmFWoWKfgg/SearchTimeline"
   graphListById* = graphql / "iTpgCtbdxrsJfyx0cFjHqg/ListByRestId"
   graphListById* = graphql / "iTpgCtbdxrsJfyx0cFjHqg/ListByRestId"
   graphListBySlug* = graphql / "-kmqNvm5Y-cVrfvBy6docg/ListBySlug"
   graphListBySlug* = graphql / "-kmqNvm5Y-cVrfvBy6docg/ListBySlug"
   graphListMembers* = graphql / "P4NpVZDqUD_7MEM84L-8nw/ListMembers"
   graphListMembers* = graphql / "P4NpVZDqUD_7MEM84L-8nw/ListMembers"
-  graphListTweets* = graphql / "jZntL0oVJSdjhmPcdbw_eA/ListLatestTweetsTimeline"
+  graphListTweets* = graphql / "BbGLL1ZfMibdFNWlk7a0Pw/ListTimeline"
 
 
   timelineParams* = {
   timelineParams* = {
     "include_profile_interstitial_type": "0",
     "include_profile_interstitial_type": "0",
@@ -49,10 +49,13 @@ const
   }.toSeq
   }.toSeq
 
 
   gqlFeatures* = """{
   gqlFeatures* = """{
+  "android_graphql_skip_api_media_color_palette": false,
   "blue_business_profile_image_shape_enabled": false,
   "blue_business_profile_image_shape_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,
   "graphql_is_translatable_rweb_tweet_is_translatable_enabled": false,
   "graphql_is_translatable_rweb_tweet_is_translatable_enabled": false,
+  "hidden_profile_likes_enabled": false,
   "highlights_tweets_tab_ui_enabled": false,
   "highlights_tweets_tab_ui_enabled": false,
   "interactive_text_enabled": false,
   "interactive_text_enabled": false,
   "longform_notetweets_consumption_enabled": true,
   "longform_notetweets_consumption_enabled": true,
@@ -64,15 +67,25 @@ const
   "responsive_web_graphql_exclude_directive_enabled": true,
   "responsive_web_graphql_exclude_directive_enabled": true,
   "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
   "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
   "responsive_web_graphql_timeline_navigation_enabled": false,
   "responsive_web_graphql_timeline_navigation_enabled": false,
+  "responsive_web_media_download_video_enabled": false,
   "responsive_web_text_conversations_enabled": false,
   "responsive_web_text_conversations_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,
   "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,
+  "subscriptions_verification_info_enabled": true,
+  "subscriptions_verification_info_reason_enabled": true,
+  "subscriptions_verification_info_verified_since_enabled": true,
+  "super_follow_badge_privacy_enabled": false,
+  "super_follow_exclusive_tweet_notifications_enabled": false,
+  "super_follow_tweet_api_enabled": false,
+  "super_follow_user_api_enabled": false,
   "tweet_awards_web_tipping_enabled": false,
   "tweet_awards_web_tipping_enabled": false,
   "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": false,
   "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": false,
   "tweetypie_unmention_optimization_enabled": false,
   "tweetypie_unmention_optimization_enabled": false,
+  "unified_cards_ad_metadata_container_dynamic_card_content_query_enabled": false,
   "verified_phone_label_enabled": false,
   "verified_phone_label_enabled": false,
   "vibe_api_enabled": false,
   "vibe_api_enabled": false,
   "view_counts_everywhere_api_enabled": false
   "view_counts_everywhere_api_enabled": false
@@ -81,41 +94,15 @@ const
   tweetVariables* = """{
   tweetVariables* = """{
   "focalTweetId": "$1",
   "focalTweetId": "$1",
   $2
   $2
-  "withBirdwatchNotes": false,
-  "includePromotedContent": false,
-  "withDownvotePerspective": false,
-  "withReactionsMetadata": false,
-  "withReactionsPerspective": false,
-  "withVoice": false
-}"""
-
-  tweetResultVariables* = """{
-  "tweetId": "$1",
-  "includePromotedContent": false,
-  "withDownvotePerspective": false,
-  "withReactionsMetadata": false,
-  "withReactionsPerspective": false,
-  "withVoice": false,
-  "withCommunity": false
+  "includeHasBirdwatchNotes": false
 }"""
 }"""
 
 
   userTweetsVariables* = """{
   userTweetsVariables* = """{
-  "userId": "$1", $2
-  "count": 20,
-  "includePromotedContent": false,
-  "withDownvotePerspective": false,
-  "withReactionsMetadata": false,
-  "withReactionsPerspective": false,
-  "withVoice": false,
-  "withV2Timeline": true
+  "rest_id": "$1", $2
+  "count": 20
 }"""
 }"""
 
 
   listTweetsVariables* = """{
   listTweetsVariables* = """{
-  "listId": "$1", $2
-  "count": 20,
-  "includePromotedContent": false,
-  "withDownvotePerspective": false,
-  "withReactionsMetadata": false,
-  "withReactionsPerspective": false,
-  "withVoice": false
+  "rest_id": "$1", $2
+  "count": 20
 }"""
 }"""

+ 4 - 4
src/experimental/parser/graphql.nim

@@ -9,12 +9,12 @@ proc parseGraphUser*(json: string): User =
 
 
   let raw = json.fromJson(GraphUser)
   let raw = json.fromJson(GraphUser)
 
 
-  if raw.data.user.result.reason.get("") == "Suspended":
+  if raw.data.userResult.result.unavailableReason.get("") == "Suspended":
     return User(suspended: true)
     return User(suspended: true)
 
 
-  result = toUser raw.data.user.result.legacy
-  result.id = raw.data.user.result.restId
-  result.verified = result.verified or raw.data.user.result.isBlueVerified
+  result = toUser raw.data.userResult.result.legacy
+  result.id = raw.data.userResult.result.restId
+  result.verified = result.verified or raw.data.userResult.result.isBlueVerified
 
 
 proc parseGraphListMembers*(json, cursor: string): Result[User] =
 proc parseGraphListMembers*(json, cursor: string): Result[User] =
   result = Result[User](
   result = Result[User](

+ 2 - 2
src/experimental/types/graphuser.nim

@@ -3,7 +3,7 @@ import user
 
 
 type
 type
   GraphUser* = object
   GraphUser* = object
-    data*: tuple[user: UserData]
+    data*: tuple[userResult: UserData]
 
 
   UserData* = object
   UserData* = object
     result*: UserResult
     result*: UserResult
@@ -12,4 +12,4 @@ type
     legacy*: RawUser
     legacy*: RawUser
     restId*: string
     restId*: string
     isBlueVerified*: bool
     isBlueVerified*: bool
-    reason*: Option[string]
+    unavailableReason*: Option[string]

+ 42 - 40
src/parser.nim

@@ -29,7 +29,7 @@ proc parseUser(js: JsonNode; id=""): User =
   result.expandUserEntities(js)
   result.expandUserEntities(js)
 
 
 proc parseGraphUser(js: JsonNode): User =
 proc parseGraphUser(js: JsonNode): User =
-  let user = ? js{"user_results", "result"}
+  let user = ? js{"user_result", "result"}
   result = parseUser(user{"legacy"})
   result = parseUser(user{"legacy"})
 
 
   if "is_blue_verified" in user:
   if "is_blue_verified" in user:
@@ -262,6 +262,11 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
         result.gif = some(parseGif(m))
         result.gif = some(parseGif(m))
       else: discard
       else: discard
 
 
+      with url, m{"url"}:
+        if result.text.endsWith(url.getStr):
+          result.text.removeSuffix(url.getStr)
+          result.text = result.text.strip()
+
   with jsWithheld, js{"withheld_in_countries"}:
   with jsWithheld, js{"withheld_in_countries"}:
     let withheldInCountries: seq[string] =
     let withheldInCountries: seq[string] =
       if jsWithheld.kind != JArray: @[]
       if jsWithheld.kind != JArray: @[]
@@ -294,16 +299,6 @@ proc finalizeTweet(global: GlobalObjects; id: string): Tweet =
     else:
     else:
       result.retweet = some Tweet()
       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 =
 proc parseGlobalObjects(js: JsonNode): GlobalObjects =
   result = GlobalObjects()
   result = GlobalObjects()
   let
   let
@@ -314,7 +309,7 @@ proc parseGlobalObjects(js: JsonNode): GlobalObjects =
     result.users[k] = parseUser(v, k)
     result.users[k] = parseUser(v, k)
 
 
   for k, v in tweets:
   for k, v in tweets:
-    var tweet = parseTweet(v, v{"card"})
+    var tweet = parseTweet(v, v{"tweet_card"})
     if tweet.user.id in result.users:
     if tweet.user.id in result.users:
       tweet.user = result.users[tweet.user.id]
       tweet.user = result.users[tweet.user.id]
     result.tweets[k] = tweet
     result.tweets[k] = tweet
@@ -324,11 +319,6 @@ proc parseInstructions[T](res: var Result[T]; global: GlobalObjects; js: JsonNod
     return
     return
 
 
   for i in js:
   for i in js:
-    when T is Tweet:
-      if res.beginning and i{"pinEntry"}.notNull:
-        with pin, parsePin(i, global):
-          res.content.add pin
-
     with r, i{"replaceEntry", "entry"}:
     with r, i{"replaceEntry", "entry"}:
       if "top" in r{"entryId"}.getStr:
       if "top" in r{"entryId"}.getStr:
         res.top = r.getCursor
         res.top = r.getCursor
@@ -369,7 +359,7 @@ proc parseTimeline*(js: JsonNode; after=""): Timeline =
 proc parsePhotoRail*(js: JsonNode): PhotoRail =
 proc parsePhotoRail*(js: JsonNode): PhotoRail =
   for tweet in js:
   for tweet in js:
     let
     let
-      t = parseTweet(tweet, js{"card"})
+      t = parseTweet(tweet, js{"tweet_card"})
       url = if t.photos.len > 0: t.photos[0]
       url = if t.photos.len > 0: t.photos[0]
             elif t.video.isSome: get(t.video).thumb
             elif t.video.isSome: get(t.video).thumb
             elif t.gif.isSome: get(t.gif).thumb
             elif t.gif.isSome: get(t.gif).thumb
@@ -387,13 +377,17 @@ proc parseGraphTweet(js: JsonNode): Tweet =
   of "TweetUnavailable":
   of "TweetUnavailable":
     return Tweet()
     return Tweet()
   of "TweetTombstone":
   of "TweetTombstone":
-    return Tweet(text: js{"tombstone", "text"}.getTombstone)
+    with text, js{"tombstone", "richText"}:
+      return Tweet(text: text.getTombstone)
+    with text, js{"tombstone", "text"}:
+      return Tweet(text: text.getTombstone)
+    return Tweet()
   of "TweetPreviewDisplay":
   of "TweetPreviewDisplay":
     return Tweet(text: "You're unable to view this Tweet because it's only available to the Subscribers of the account owner.")
     return Tweet(text: "You're unable to view this Tweet because it's only available to the Subscribers of the account owner.")
   of "TweetWithVisibilityResults":
   of "TweetWithVisibilityResults":
     return parseGraphTweet(js{"tweet"})
     return parseGraphTweet(js{"tweet"})
 
 
-  var jsCard = copy(js{"card", "legacy"})
+  var jsCard = copy(js{"tweet_card", "legacy"})
   if jsCard.kind != JNull:
   if jsCard.kind != JNull:
     var values = newJObject()
     var values = newJObject()
     for val in jsCard["binding_values"]:
     for val in jsCard["binding_values"]:
@@ -401,6 +395,7 @@ proc parseGraphTweet(js: JsonNode): Tweet =
     jsCard["binding_values"] = values
     jsCard["binding_values"] = values
 
 
   result = parseTweet(js{"legacy"}, jsCard)
   result = parseTweet(js{"legacy"}, jsCard)
+  result.id = js{"rest_id"}.getId
   result.user = parseGraphUser(js{"core"})
   result.user = parseGraphUser(js{"core"})
 
 
   with noteTweet, js{"note_tweet", "note_tweet_results", "result"}:
   with noteTweet, js{"note_tweet", "note_tweet_results", "result"}:
@@ -414,32 +409,31 @@ proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] =
   for t in js{"content", "items"}:
   for t in js{"content", "items"}:
     let entryId = t{"entryId"}.getStr
     let entryId = t{"entryId"}.getStr
     if "cursor-showmore" in entryId:
     if "cursor-showmore" in entryId:
-      let cursor = t{"item", "itemContent", "value"}
+      let cursor = t{"item", "content", "value"}
       result.thread.cursor = cursor.getStr
       result.thread.cursor = cursor.getStr
       result.thread.hasMore = true
       result.thread.hasMore = true
     elif "tweet" in entryId:
     elif "tweet" in entryId:
-      let tweet = parseGraphTweet(t{"item", "itemContent", "tweet_results", "result"})
+      let tweet = parseGraphTweet(t{"item", "content", "tweetResult", "result"})
       result.thread.content.add tweet
       result.thread.content.add tweet
 
 
-      if t{"item", "itemContent", "tweetDisplayType"}.getStr == "SelfThread":
+      if t{"item", "content", "tweetDisplayType"}.getStr == "SelfThread":
         result.self = true
         result.self = true
 
 
 proc parseGraphTweetResult*(js: JsonNode): Tweet =
 proc parseGraphTweetResult*(js: JsonNode): Tweet =
-  with tweet, js{"data", "tweetResult", "result"}:
+  with tweet, js{"data", "tweet_result", "result"}:
     result = parseGraphTweet(tweet)
     result = parseGraphTweet(tweet)
 
 
 proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
 proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
   result = Conversation(replies: Result[Chain](beginning: true))
   result = Conversation(replies: Result[Chain](beginning: true))
 
 
-  let instructions = ? js{"data", "threaded_conversation_with_injections", "instructions"}
+  let instructions = ? js{"data", "timeline_response", "instructions"}
   if instructions.len == 0:
   if instructions.len == 0:
     return
     return
 
 
   for e in instructions[0]{"entries"}:
   for e in instructions[0]{"entries"}:
     let entryId = e{"entryId"}.getStr
     let entryId = e{"entryId"}.getStr
-    # echo entryId
     if entryId.startsWith("tweet"):
     if entryId.startsWith("tweet"):
-      with tweetResult, e{"content", "itemContent", "tweet_results", "result"}:
+      with tweetResult, e{"content", "content", "tweetResult", "result"}:
         let tweet = parseGraphTweet(tweetResult)
         let tweet = parseGraphTweet(tweetResult)
 
 
         if not tweet.available:
         if not tweet.available:
@@ -454,7 +448,7 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
       let tweet = Tweet(
       let tweet = Tweet(
         id: parseBiggestInt(id),
         id: parseBiggestInt(id),
         available: false,
         available: false,
-        text: e{"content", "itemContent", "tombstoneInfo", "richText"}.getTombstone
+        text: e{"content", "content", "tombstoneInfo", "richText"}.getTombstone
       )
       )
 
 
       if id == tweetId:
       if id == tweetId:
@@ -468,34 +462,42 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
       else:
       else:
         result.replies.content.add thread
         result.replies.content.add thread
     elif entryId.startsWith("cursor-bottom"):
     elif entryId.startsWith("cursor-bottom"):
-      result.replies.bottom = e{"content", "itemContent", "value"}.getStr
+      result.replies.bottom = e{"content", "content", "value"}.getStr
 
 
-proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Timeline =
-  result = Timeline(beginning: after.len == 0)
+proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile =
+  result = Profile(tweets: Timeline(beginning: after.len == 0))
 
 
   let instructions =
   let instructions =
-    if root == "list": ? js{"data", "list", "tweets_timeline", "timeline", "instructions"}
-    else: ? js{"data", "user", "result", "timeline_v2", "timeline", "instructions"}
+    if root == "list": ? js{"data", "list", "timeline_response", "timeline", "instructions"}
+    else: ? js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"}
 
 
   if instructions.len == 0:
   if instructions.len == 0:
     return
     return
 
 
   for i in instructions:
   for i in instructions:
-    if i{"type"}.getStr == "TimelineAddEntries":
+    if i{"__typename"}.getStr == "TimelineAddEntries":
       for e in i{"entries"}:
       for e in i{"entries"}:
         let entryId = e{"entryId"}.getStr
         let entryId = e{"entryId"}.getStr
         if entryId.startsWith("tweet"):
         if entryId.startsWith("tweet"):
-          with tweetResult, e{"content", "itemContent", "tweet_results", "result"}:
+          with tweetResult, e{"content", "content", "tweetResult", "result"}:
             let tweet = parseGraphTweet(tweetResult)
             let tweet = parseGraphTweet(tweetResult)
             if not tweet.available:
             if not tweet.available:
               tweet.id = parseBiggestInt(entryId.getId())
               tweet.id = parseBiggestInt(entryId.getId())
-            result.content.add tweet
-        elif entryId.startsWith("profile-conversation") or entryId.startsWith("homeConversation"):
+            result.tweets.content.add tweet
+        elif "-conversation-" in entryId or entryId.startsWith("homeConversation"):
           let (thread, self) = parseGraphThread(e)
           let (thread, self) = parseGraphThread(e)
-          for tweet in thread.content:
-            result.content.add tweet
+          result.tweets.content.add thread
         elif entryId.startsWith("cursor-bottom"):
         elif entryId.startsWith("cursor-bottom"):
-          result.bottom = e{"content", "value"}.getStr
+          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)
+        tweet.pinned = true
+        if not tweet.available and tweet.tombstone.len == 0:
+          let entryId = i{"entry", "entryId"}.getEntryId
+          if entryId.len > 0:
+            tweet.id = parseBiggestInt(entryId)
+        result.pinned = some tweet
 
 
 proc parseGraphSearch*(js: JsonNode; after=""): Timeline =
 proc parseGraphSearch*(js: JsonNode; after=""): Timeline =
   result = Timeline(beginning: after.len == 0)
   result = Timeline(beginning: after.len == 0)

+ 6 - 6
src/routes/embed.nim

@@ -10,22 +10,22 @@ export api, embed, vdom, tweet, general, router_utils
 proc createEmbedRouter*(cfg: Config) =
 proc createEmbedRouter*(cfg: Config) =
   router embed:
   router embed:
     get "/i/videos/tweet/@id":
     get "/i/videos/tweet/@id":
-      let convo = await getTweet(@"id")
-      if convo == nil or convo.tweet == nil or convo.tweet.video.isNone:
+      let tweet = await getGraphTweetResult(@"id")
+      if tweet == nil or tweet.video.isNone:
         resp Http404
         resp Http404
 
 
-      resp renderVideoEmbed(convo.tweet, cfg, request)
+      resp renderVideoEmbed(tweet, cfg, request)
 
 
     get "/@user/status/@id/embed":
     get "/@user/status/@id/embed":
       let
       let
-        convo = await getTweet(@"id")
+        tweet = await getGraphTweetResult(@"id")
         prefs = cookiePrefs()
         prefs = cookiePrefs()
         path = getPath()
         path = getPath()
 
 
-      if convo == nil or convo.tweet == nil:
+      if tweet == nil:
         resp Http404
         resp Http404
 
 
-      resp renderTweetEmbed(convo.tweet, path, prefs, cfg, request)
+      resp renderTweetEmbed(tweet, path, prefs, cfg, request)
 
 
     get "/embed/Tweet.html":
     get "/embed/Tweet.html":
       let id = @"id"
       let id = @"id"

+ 25 - 27
src/routes/rss.nim

@@ -27,14 +27,12 @@ proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async.
   else:
   else:
     var q = query
     var q = query
     q.fromUser = names
     q.fromUser = names
-    profile = Profile(
-      tweets: await getGraphSearch(q, after),
-      # this is kinda dumb
-      user: User(
-        username: name,
-        fullname: names.join(" | "),
-        userpic: "https://abs.twimg.com/sticky/default_profile_images/default_profile.png"
-      )
+    profile = await getGraphSearch(q, after)
+    # this is kinda dumb
+    profile.user = User(
+      username: name,
+      fullname: names.join(" | "),
+      userpic: "https://abs.twimg.com/sticky/default_profile_images/default_profile.png"
     )
     )
 
 
   if profile.user.suspended:
   if profile.user.suspended:
@@ -61,29 +59,29 @@ template respRss*(rss, page) =
 
 
 proc createRssRouter*(cfg: Config) =
 proc createRssRouter*(cfg: Config) =
   router rss:
   router rss:
-    get "/search/rss":
-      cond cfg.enableRss
-      if @"q".len > 200:
-        resp Http400, showError("Search input too long.", cfg)
+    # get "/search/rss":
+    #   cond cfg.enableRss
+    #   if @"q".len > 200:
+    #     resp Http400, showError("Search input too long.", cfg)
 
 
-      let query = initQuery(params(request))
-      if query.kind != tweets:
-        resp Http400, showError("Only Tweet searches are allowed for RSS feeds.", cfg)
+    #   let query = initQuery(params(request))
+    #   if query.kind != tweets:
+    #     resp Http400, showError("Only Tweet searches are allowed for RSS feeds.", cfg)
 
 
-      let
-        cursor = getCursor()
-        key = redisKey("search", $hash(genQueryUrl(query)), cursor)
+    #   let
+    #     cursor = getCursor()
+    #     key = redisKey("search", $hash(genQueryUrl(query)), cursor)
 
 
-      var rss = await getCachedRss(key)
-      if rss.cursor.len > 0:
-        respRss(rss, "Search")
+    #   var rss = await getCachedRss(key)
+    #   if rss.cursor.len > 0:
+    #     respRss(rss, "Search")
 
 
-      let tweets = await getGraphSearch(query, cursor)
-      rss.cursor = tweets.bottom
-      rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg)
+    #   let tweets = await getGraphSearch(query, cursor)
+    #   rss.cursor = tweets.bottom
+    #   rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg)
 
 
-      await cacheRss(key, rss)
-      respRss(rss, "Search")
+    #   await cacheRss(key, rss)
+    #   respRss(rss, "Search")
 
 
     get "/@name/rss":
     get "/@name/rss":
       cond cfg.enableRss
       cond cfg.enableRss
@@ -112,7 +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 "search": initQuery(params(request), name=name)
+          # of "search": initQuery(params(request), name=name)
           else: Query(fromUser: @[name])
           else: Query(fromUser: @[name])
 
 
       let searchKey = if tab != "search": ""
       let searchKey = if tab != "search": ""

+ 9 - 5
src/routes/search.nim

@@ -34,11 +34,15 @@ proc createSearchRouter*(cfg: Config) =
           users = Result[User](beginning: true, query: query)
           users = Result[User](beginning: true, query: query)
         resp renderMain(renderUserSearch(users, prefs), request, cfg, prefs, title)
         resp renderMain(renderUserSearch(users, prefs), request, cfg, prefs, title)
       of tweets:
       of tweets:
-        let
-          tweets = await getGraphSearch(query, getCursor())
-          rss = "/search/rss?" & genQueryUrl(query)
-        resp renderMain(renderTweetSearch(tweets, prefs, getPath()),
-                        request, cfg, prefs, title, rss=rss)
+        # let
+        #   tweets = await getGraphSearch(query, getCursor())
+        #   rss = "/search/rss?" & genQueryUrl(query)
+        # resp renderMain(renderTweetSearch(tweets, prefs, getPath()),
+        #                 request, cfg, prefs, title, rss=rss)
+        var fakeTimeline = Timeline(beginning: true)
+        fakeTimeline.content.add Tweet(tombstone: "Tweet search is unavailable for now")
+
+        resp renderMain(renderTweetSearch(fakeTimeline, prefs, getPath()), request, cfg, prefs, title)
       else:
       else:
         resp Http404, showError("Invalid search", cfg)
         resp Http404, showError("Invalid search", cfg)
 
 

+ 18 - 25
src/routes/timeline.nim

@@ -45,34 +45,24 @@ proc fetchProfile*(after: string; query: Query; skipRail=false;
     after.setLen 0
     after.setLen 0
 
 
   let
   let
-    timeline =
-      case query.kind
-      of posts: getGraphUserTweets(userId, TimelineKind.tweets, after)
-      of replies: getGraphUserTweets(userId, TimelineKind.replies, after)
-      of media: getGraphUserTweets(userId, TimelineKind.media, after)
-      else: getGraphSearch(query, after)
-
     rail =
     rail =
       skipIf(skipRail or query.kind == media, @[]):
       skipIf(skipRail or query.kind == media, @[]):
         getCachedPhotoRail(name)
         getCachedPhotoRail(name)
 
 
-    user = await getCachedUser(name)
+    user = getCachedUser(name)
 
 
-  var pinned: Option[Tweet]
-  if not skipPinned and user.pinnedTweet > 0 and
-     after.len == 0 and query.kind in {posts, replies}:
-    let tweet = await getCachedTweet(user.pinnedTweet)
-    if not tweet.isNil:
-      tweet.pinned = true
-      tweet.user = user
-      pinned = some tweet
+  result =
+    case query.kind
+    of posts: await getGraphUserTweets(userId, TimelineKind.tweets, after)
+    of replies: await getGraphUserTweets(userId, TimelineKind.replies, after)
+    of media: await getGraphUserTweets(userId, TimelineKind.media, after)
+    else: Profile(tweets: Timeline(beginning: true, content: @[Chain(content:
+      @[Tweet(tombstone: "Tweet search is unavailable for now")]
+    )]))
+    # else: await getGraphSearch(query, after)
 
 
-  result = Profile(
-    user: user,
-    pinned: pinned,
-    tweets: await timeline,
-    photoRail: await rail
-  )
+  result.user = await user
+  result.photoRail = await rail
 
 
   if result.user.protected or result.user.suspended:
   if result.user.protected or result.user.suspended:
     return
     return
@@ -83,8 +73,11 @@ proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs;
                    rss, after: string): Future[string] {.async.} =
                    rss, after: string): Future[string] {.async.} =
   if query.fromUser.len != 1:
   if query.fromUser.len != 1:
     let
     let
-      timeline = await getGraphSearch(query, after)
-      html = renderTweetSearch(timeline, prefs, getPath())
+      # timeline = await getGraphSearch(query, after)
+      timeline = Profile(tweets: Timeline(beginning: true, content: @[Chain(content:
+        @[Tweet(tombstone: "This features is unavailable for now")]
+      )]))
+      html = renderTweetSearch(timeline.tweets, 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, skipPinned=prefs.hidePins)
@@ -138,7 +131,7 @@ proc createTimelineRouter*(cfg: Config) =
       # used for the infinite scroll feature
       # used for the infinite scroll feature
       if @"scroll".len > 0:
       if @"scroll".len > 0:
         if query.fromUser.len != 1:
         if query.fromUser.len != 1:
-          var timeline = await getGraphSearch(query, after)
+          var timeline = (await getGraphSearch(query, after)).tweets
           if timeline.content.len == 0: resp Http404
           if timeline.content.len == 0: resp Http404
           timeline.beginning = true
           timeline.beginning = true
           resp $renderTweetSearch(timeline, prefs, getPath())
           resp $renderTweetSearch(timeline, prefs, getPath())

+ 26 - 0
src/sass/tweet/thread.scss

@@ -110,3 +110,29 @@
     margin-left: 58px;
     margin-left: 58px;
     padding: 7px 0;
     padding: 7px 0;
 }
 }
+
+.timeline-item.thread.more-replies-thread {
+    padding: 0 0.75em;
+
+    &::before {
+        top: 40px;
+        margin-bottom: 31px;
+    }
+
+    .more-replies {
+        display: flex;
+        padding-top: unset !important;
+        margin-top: 8px;
+
+        &::before {
+            display: inline-block;
+            position: relative;
+            top: -1px;
+            line-height: 0.4em;
+        }
+
+        .more-replies-text {
+            display: inline;
+        }
+    }
+}

+ 4 - 5
src/tokens.nim

@@ -41,11 +41,10 @@ proc getPoolJson*(): JsonNode =
       let
       let
         maxReqs =
         maxReqs =
           case api
           case api
-          of Api.timeline: 187
-          of Api.listMembers, Api.listBySlug, Api.list, Api.listTweets,
-             Api.userTweets, Api.userTweetsAndReplies, Api.userMedia,
-             Api.userRestId, Api.userScreenName,
-             Api.tweetDetail, Api.tweetResult, Api.search: 500
+          of Api.timeline: 180
+          of Api.userTweets, Api.userTweetsAndReplies, Api.userRestId,
+             Api.userScreenName, Api.tweetDetail, Api.tweetResult, Api.search: 500
+          of Api.list, Api.listTweets, Api.listMembers, Api.listBySlug, Api.userMedia: 500
           of Api.userSearch: 900
           of Api.userSearch: 900
         reqs = maxReqs - token.apis[api].remaining
         reqs = maxReqs - token.apis[api].remaining
 
 

+ 4 - 1
src/types.nim

@@ -222,7 +222,7 @@ type
     after*: Chain
     after*: Chain
     replies*: Result[Chain]
     replies*: Result[Chain]
 
 
-  Timeline* = Result[Tweet]
+  Timeline* = Result[Chain]
 
 
   Profile* = object
   Profile* = object
     user*: User
     user*: User
@@ -274,3 +274,6 @@ type
 
 
 proc contains*(thread: Chain; tweet: Tweet): bool =
 proc contains*(thread: Chain; tweet: Tweet): bool =
   thread.content.anyIt(it.id == tweet.id)
   thread.content.anyIt(it.id == tweet.id)
+
+proc add*(timeline: var seq[Chain]; tweet: Tweet) =
+  timeline.add Chain(content: @[tweet])

+ 24 - 19
src/views/rss.nimf

@@ -56,24 +56,29 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
 #end if
 #end if
 #end proc
 #end proc
 #
 #
-#proc renderRssTweets(tweets: seq[Tweet]; cfg: Config): string =
+#proc renderRssTweets(tweets: seq[Chain]; cfg: Config; userId=""): string =
 #let urlPrefix = getUrlPrefix(cfg)
 #let urlPrefix = getUrlPrefix(cfg)
 #var links: seq[string]
 #var links: seq[string]
-#for t in tweets:
-#  let retweet = if t.retweet.isSome: t.user.username else: ""
-#  let tweet = if retweet.len > 0: t.retweet.get else: t
-#  let link = getLink(tweet)
-#  if link in links: continue
-#  end if
-#  links.add link
-    <item>
-      <title>${getTitle(tweet, retweet)}</title>
-      <dc:creator>@${tweet.user.username}</dc:creator>
-      <description><![CDATA[${renderRssTweet(tweet, cfg).strip(chars={'\n'})}]]></description>
-      <pubDate>${getRfc822Time(tweet)}</pubDate>
-      <guid>${urlPrefix & link}</guid>
-      <link>${urlPrefix & link}</link>
-    </item>
+#for c in tweets:
+#  for t in c.content:
+#    if userId.len > 0 and t.user.id != userId: continue
+#    end if
+#
+#    let retweet = if t.retweet.isSome: t.user.username else: ""
+#    let tweet = if retweet.len > 0: t.retweet.get else: t
+#    let link = getLink(tweet)
+#    if link in links: continue
+#    end if
+#    links.add link
+      <item>
+        <title>${getTitle(tweet, retweet)}</title>
+        <dc:creator>@${tweet.user.username}</dc:creator>
+        <description><![CDATA[${renderRssTweet(tweet, cfg).strip(chars={'\n'})}]]></description>
+        <pubDate>${getRfc822Time(tweet)}</pubDate>
+        <guid>${urlPrefix & link}</guid>
+        <link>${urlPrefix & link}</link>
+      </item>
+#  end for
 #end for
 #end for
 #end proc
 #end proc
 #
 #
@@ -102,13 +107,13 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
       <height>128</height>
       <height>128</height>
     </image>
     </image>
 #if profile.tweets.content.len > 0:
 #if profile.tweets.content.len > 0:
-${renderRssTweets(profile.tweets.content, cfg)}
+${renderRssTweets(profile.tweets.content, cfg, userId=profile.user.id)}
 #end if
 #end if
   </channel>
   </channel>
 </rss>
 </rss>
 #end proc
 #end proc
 #
 #
-#proc renderListRss*(tweets: seq[Tweet]; list: List; cfg: Config): string =
+#proc renderListRss*(tweets: seq[Chain]; list: List; cfg: Config): string =
 #let link = &"{getUrlPrefix(cfg)}/i/lists/{list.id}"
 #let link = &"{getUrlPrefix(cfg)}/i/lists/{list.id}"
 #result = ""
 #result = ""
 <?xml version="1.0" encoding="UTF-8"?>
 <?xml version="1.0" encoding="UTF-8"?>
@@ -125,7 +130,7 @@ ${renderRssTweets(tweets, cfg)}
 </rss>
 </rss>
 #end proc
 #end proc
 #
 #
-#proc renderSearchRss*(tweets: seq[Tweet]; name, param: string; cfg: Config): string =
+#proc renderSearchRss*(tweets: seq[Chain]; name, param: string; cfg: Config): string =
 #let link = &"{getUrlPrefix(cfg)}/search"
 #let link = &"{getUrlPrefix(cfg)}/search"
 #let escName = xmltree.escape(name)
 #let escName = xmltree.escape(name)
 #result = ""
 #result = ""

+ 1 - 1
src/views/search.nim

@@ -88,7 +88,7 @@ proc renderSearchPanel*(query: Query): VNode =
           span(class="search-title"): text "Near"
           span(class="search-title"): text "Near"
           genInput("near", "", query.near, "Location...", autofocus=false)
           genInput("near", "", query.near, "Location...", autofocus=false)
 
 
-proc renderTweetSearch*(results: Result[Tweet]; prefs: Prefs; path: string;
+proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string;
                         pinned=none(Tweet)): VNode =
                         pinned=none(Tweet)): VNode =
   let query = results.query
   let query = results.query
   buildHtml(tdiv(class="timeline-container")):
   buildHtml(tdiv(class="timeline-container")):

+ 23 - 25
src/views/timeline.nim

@@ -1,5 +1,5 @@
 # SPDX-License-Identifier: AGPL-3.0-only
 # SPDX-License-Identifier: AGPL-3.0-only
-import strutils, strformat, sequtils, algorithm, uri, options
+import strutils, strformat, algorithm, uri, options
 import karax/[karaxdsl, vdom]
 import karax/[karaxdsl, vdom]
 
 
 import ".."/[types, query, formatters]
 import ".."/[types, query, formatters]
@@ -43,20 +43,18 @@ proc renderThread(thread: seq[Tweet]; prefs: Prefs; path: string): VNode =
   buildHtml(tdiv(class="thread-line")):
   buildHtml(tdiv(class="thread-line")):
     let sortedThread = thread.sortedByIt(it.id)
     let sortedThread = thread.sortedByIt(it.id)
     for i, tweet in sortedThread:
     for i, tweet in sortedThread:
+      # thread has a gap, display "more replies" link
+      if i > 0 and tweet.replyId != sortedThread[i - 1].id:
+        tdiv(class="timeline-item thread more-replies-thread"):
+          tdiv(class="more-replies"):
+            a(class="more-replies-text", href=getLink(tweet)):
+              text "more replies"
+
       let show = i == thread.high and sortedThread[0].id != tweet.threadId
       let show = i == thread.high and sortedThread[0].id != tweet.threadId
       let header = if tweet.pinned or tweet.retweet.isSome: "with-header " else: ""
       let header = if tweet.pinned or tweet.retweet.isSome: "with-header " else: ""
       renderTweet(tweet, prefs, path, class=(header & "thread"),
       renderTweet(tweet, prefs, path, class=(header & "thread"),
                   index=i, last=(i == thread.high), showThread=show)
                   index=i, last=(i == thread.high), showThread=show)
 
 
-proc threadFilter(tweets: openArray[Tweet]; threads: openArray[int64]; it: Tweet): seq[Tweet] =
-  result = @[it]
-  if it.retweet.isSome or it.replyId in threads: return
-  for t in tweets:
-    if t.id == result[0].replyId:
-      result.insert t
-    elif t.replyId == result[0].id:
-      result.add t
-
 proc renderUser(user: User; prefs: Prefs): VNode =
 proc renderUser(user: User; prefs: Prefs): VNode =
   buildHtml(tdiv(class="timeline-item")):
   buildHtml(tdiv(class="timeline-item")):
     a(class="tweet-link", href=("/" & user.username))
     a(class="tweet-link", href=("/" & user.username))
@@ -89,7 +87,7 @@ proc renderTimelineUsers*(results: Result[User]; prefs: Prefs; path=""): VNode =
     else:
     else:
       renderNoMore()
       renderNoMore()
 
 
-proc renderTimelineTweets*(results: Result[Tweet]; prefs: Prefs; path: string;
+proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string;
                            pinned=none(Tweet)): VNode =
                            pinned=none(Tweet)): VNode =
   buildHtml(tdiv(class="timeline")):
   buildHtml(tdiv(class="timeline")):
     if not results.beginning:
     if not results.beginning:
@@ -105,26 +103,26 @@ proc renderTimelineTweets*(results: Result[Tweet]; prefs: Prefs; path: string;
       else:
       else:
         renderNoneFound()
         renderNoneFound()
     else:
     else:
-      var
-        threads: seq[int64]
-        retweets: seq[int64]
+      var retweets: seq[int64]
 
 
-      for tweet in results.content:
-        let rt = if tweet.retweet.isSome: get(tweet.retweet).id else: 0
+      for thread in results.content:
+        if thread.content.len == 1:
+          let
+            tweet = thread.content[0]
+            retweetId = if tweet.retweet.isSome: get(tweet.retweet).id else: 0
 
 
-        if tweet.id in threads or rt in retweets or tweet.id in retweets or
-           tweet.pinned and prefs.hidePins: continue
+          if retweetId in retweets or tweet.id in retweets or
+             tweet.pinned and prefs.hidePins:
+            continue
 
 
-        let thread = results.content.threadFilter(threads, tweet)
-        if thread.len < 2:
           var hasThread = tweet.hasThread
           var hasThread = tweet.hasThread
-          if rt != 0:
-            retweets &= rt
+          if retweetId != 0 and tweet.retweet.isSome:
+            retweets &= retweetId
             hasThread = get(tweet.retweet).hasThread
             hasThread = get(tweet.retweet).hasThread
           renderTweet(tweet, prefs, path, showThread=hasThread)
           renderTweet(tweet, prefs, path, showThread=hasThread)
         else:
         else:
-          renderThread(thread, prefs, path)
-          threads &= thread.mapIt(it.id)
+          renderThread(thread.content, prefs, path)
 
 
-      renderMore(results.query, results.bottom)
+      if results.bottom.len > 0:
+        renderMore(results.query, results.bottom)
       renderToTop()
       renderToTop()

+ 10 - 8
src/views/tweet.nim

@@ -14,15 +14,14 @@ proc renderMiniAvatar(user: User; prefs: Prefs): VNode =
   buildHtml():
   buildHtml():
     img(class=(prefs.getAvatarClass & " mini"), src=url)
     img(class=(prefs.getAvatarClass & " mini"), src=url)
 
 
-proc renderHeader(tweet: Tweet; retweet: string; prefs: Prefs): VNode =
+proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): VNode =
   buildHtml(tdiv):
   buildHtml(tdiv):
-    if retweet.len > 0:
-      tdiv(class="retweet-header"):
-        span: icon "retweet", retweet & " retweeted"
-
-    if tweet.pinned:
+    if pinned:
       tdiv(class="pinned"):
       tdiv(class="pinned"):
         span: icon "pin", "Pinned Tweet"
         span: icon "pin", "Pinned Tweet"
+    elif retweet.len > 0:
+      tdiv(class="retweet-header"):
+        span: icon "retweet", retweet & " retweeted"
 
 
     tdiv(class="tweet-header"):
     tdiv(class="tweet-header"):
       a(class="tweet-avatar", href=("/" & tweet.user.username)):
       a(class="tweet-avatar", href=("/" & tweet.user.username)):
@@ -290,7 +289,10 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
       if tweet.quote.isSome:
       if tweet.quote.isSome:
         renderQuote(tweet.quote.get(), prefs, path)
         renderQuote(tweet.quote.get(), prefs, path)
 
 
-  let fullTweet = tweet
+  let
+    fullTweet = tweet
+    pinned = tweet.pinned
+
   var retweet: string
   var retweet: string
   var tweet = fullTweet
   var tweet = fullTweet
   if tweet.retweet.isSome:
   if tweet.retweet.isSome:
@@ -303,7 +305,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
 
 
     tdiv(class="tweet-body"):
     tdiv(class="tweet-body"):
       var views = ""
       var views = ""
-      renderHeader(tweet, retweet, prefs)
+      renderHeader(tweet, retweet, pinned, prefs)
 
 
       if not afterTweet and index == 0 and tweet.reply.len > 0 and
       if not afterTweet and index == 0 and tweet.reply.len > 0 and
          (tweet.reply.len > 1 or tweet.reply[0] != tweet.user.username):
          (tweet.reply.len > 1 or tweet.reply[0] != tweet.user.username):

+ 7 - 28
tests/test_card.py

@@ -16,7 +16,12 @@ card = [
     ['FluentAI/status/1116417904831029248',
     ['FluentAI/status/1116417904831029248',
      'Amazon’s Alexa isn’t just AI — thousands of humans are listening',
      'Amazon’s Alexa isn’t just AI — thousands of humans are listening',
      'One of the only ways to improve Alexa is to have human beings check it for errors',
      'One of the only ways to improve Alexa is to have human beings check it for errors',
-     'theverge.com', True]
+     'theverge.com', True],
+
+    ['nim_lang/status/1082989146040340480',
+     'Nim in 2018: A short recap',
+     'There were several big news in the Nim world in 2018 – two new major releases, partnership with Status, and much more. But let us go chronologically.',
+     'nim-lang.org', True]
 ]
 ]
 
 
 no_thumb = [
 no_thumb = [
@@ -33,12 +38,7 @@ no_thumb = [
     ['voidtarget/status/1133028231672582145',
     ['voidtarget/status/1133028231672582145',
      'sinkingsugar/nimqt-example',
      'sinkingsugar/nimqt-example',
      'A sample of a Qt app written using mostly nim. Contribute to sinkingsugar/nimqt-example development by creating an account on GitHub.',
      'A sample of a Qt app written using mostly nim. Contribute to sinkingsugar/nimqt-example development by creating an account on GitHub.',
-     'github.com'],
-
-    ['nim_lang/status/1082989146040340480',
-     'Nim in 2018: A short recap',
-     'Posted by u/miran1 - 36 votes and 46 comments',
-     'reddit.com']
+     'github.com']
 ]
 ]
 
 
 playable = [
 playable = [
@@ -53,17 +53,6 @@ playable = [
      'youtube.com']
      'youtube.com']
 ]
 ]
 
 
-# promo = [
-    # ['BangOlufsen/status/1145698701517754368',
-    #  'Upgrade your journey', '',
-    #  'www.bang-olufsen.com'],
-
-    # ['BangOlufsen/status/1154934429900406784',
-    #  'Learn more about Beosound Shape', '',
-    #  'www.bang-olufsen.com']
-# ]
-
-
 class CardTest(BaseTestCase):
 class CardTest(BaseTestCase):
     @parameterized.expand(card)
     @parameterized.expand(card)
     def test_card(self, tweet, title, description, destination, large):
     def test_card(self, tweet, title, description, destination, large):
@@ -98,13 +87,3 @@ class CardTest(BaseTestCase):
         self.assert_element_visible('.card-overlay')
         self.assert_element_visible('.card-overlay')
         if len(description) > 0:
         if len(description) > 0:
             self.assert_text(description, c.description)
             self.assert_text(description, c.description)
-
-    # @parameterized.expand(promo)
-    # def test_card_promo(self, tweet, title, description, destination):
-    #     self.open_nitter(tweet)
-    #     c = Card(Conversation.main + " ")
-    #     self.assert_text(title, c.title)
-    #     self.assert_text(destination, c.destination)
-    #     self.assert_element_visible('.video-overlay')
-    #     if len(description) > 0:
-    #         self.assert_text(description, c.description)

+ 2 - 2
tests/test_profile.py

@@ -66,8 +66,8 @@ class ProfileTest(BaseTestCase):
         self.assert_text(f'User "{username}" not found')
         self.assert_text(f'User "{username}" not found')
 
 
     def test_suspended(self):
     def test_suspended(self):
-        self.open_nitter('user')
-        self.assert_text('User "user" has been suspended')
+        self.open_nitter('suspendme')
+        self.assert_text('User "suspendme" has been suspended')
 
 
     @parameterized.expand(banner_image)
     @parameterized.expand(banner_image)
     def test_banner_image(self, username, url):
     def test_banner_image(self, username, url):

+ 5 - 5
tests/test_search.py

@@ -2,8 +2,8 @@ from base import BaseTestCase
 from parameterized import parameterized
 from parameterized import parameterized
 
 
 
 
-class SearchTest(BaseTestCase):
-    @parameterized.expand([['@mobile_test'], ['@mobile_test_2']])
-    def test_username_search(self, username):
-        self.search_username(username)
-        self.assert_text(f'{username}')
+#class SearchTest(BaseTestCase):
+    #@parameterized.expand([['@mobile_test'], ['@mobile_test_2']])
+    #def test_username_search(self, username):
+        #self.search_username(username)
+        #self.assert_text(f'{username}')

+ 8 - 8
tests/test_tweet.py

@@ -74,9 +74,9 @@ retweet = [
     [3, 'mobile_test_8', 'mobile test 8', 'jack', '@jack', 'twttr']
     [3, 'mobile_test_8', 'mobile test 8', 'jack', '@jack', 'twttr']
 ]
 ]
 
 
-reply = [
-    ['mobile_test/with_replies', 15]
-]
+# reply = [
+#     ['mobile_test/with_replies', 15]
+# ]
 
 
 
 
 class TweetTest(BaseTestCase):
 class TweetTest(BaseTestCase):
@@ -137,8 +137,8 @@ class TweetTest(BaseTestCase):
         self.open_nitter(tweet)
         self.open_nitter(tweet)
         self.assert_text('Tweet not found', '.error-panel')
         self.assert_text('Tweet not found', '.error-panel')
 
 
-    @parameterized.expand(reply)
-    def test_thread(self, tweet, num):
-        self.open_nitter(tweet)
-        thread = self.find_element(f'.timeline > div:nth-child({num})')
-        self.assertIn(thread.get_attribute('class'), 'thread-line')
+    # @parameterized.expand(reply)
+    # def test_thread(self, tweet, num):
+    #     self.open_nitter(tweet)
+    #     thread = self.find_element(f'.timeline > div:nth-child({num})')
+    #     self.assertIn(thread.get_attribute('class'), 'thread-line')