Explorar o código

Add followers and following pages

Fixes #187
Zed hai 2 meses
pai
achega
55a331cc50

+ 22 - 0
src/api.nim

@@ -201,6 +201,28 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.}
     js = await fetchRaw(url)
   result = parseGraphListMembers(js, after)
 
+proc getGraphUserConnections(userId: string; endpoint: string; kind: QueryKind;
+                              after=""): Future[Result[User]] {.async.} =
+  if userId.len == 0: return
+  var variables = %*{
+    "userId": userId,
+    "count": 20,
+    "includePromotedContent": false,
+    "withGrokTranslatedBio": true
+  }
+  if after.len > 0:
+    variables["cursor"] = %after
+  let
+    url = apiReq(endpoint, $variables)
+    js = await fetchRaw(url)
+  result = parseGraphFollowers(js, after, kind)
+
+proc getGraphFollowers*(userId: string; after=""): Future[Result[User]] {.async.} =
+  result = await getGraphUserConnections(userId, graphFollowers, followers, after)
+
+proc getGraphFollowing*(userId: string; after=""): Future[Result[User]] {.async.} =
+  result = await getGraphUserConnections(userId, graphFollowing, following, after)
+
 proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} =
   if id.len == 0: return
   let

+ 3 - 0
src/consts.nim

@@ -42,6 +42,9 @@ const
   graphAudioSpace* = "rWRLsOhNJ2xjpI1tREYurQ/AudioSpaceById"
   restLiveStream* = "1.1/live_video_stream/status/"
 
+  graphFollowers* = "9jsVJ9l2uXUIKslHvJqIhw/Followers"
+  graphFollowing* = "OLm4oHZBfqWx8jbcEhWoFw/Following"
+
   gqlFeatures* = """{
   "rweb_video_screen_enabled": false,
   "rweb_cashtags_enabled": true,

+ 23 - 1
src/experimental/parser/graphql.nim

@@ -1,6 +1,6 @@
 import options, strutils
 import jsony
-import user, utils, ../types/[graphuser, graphlistmembers]
+import user, utils, ../types/[graphuser, graphlistmembers, graphfollowers]
 from ../../types import User, VerifiedType, Result, Query, QueryKind
 
 proc parseUserResult*(userResult: UserResult): User =
@@ -67,3 +67,25 @@ proc parseGraphListMembers*(json, cursor: string): Result[User] =
         of TimelineTimelineCursor:
           if entry.content.cursorType == "Bottom":
             result.bottom = entry.content.value
+
+proc parseGraphFollowers*(json, cursor: string; kind: QueryKind): Result[User] =
+  result = Result[User](
+    beginning: cursor.len == 0,
+    query: Query(kind: kind)
+  )
+
+  if json.len == 0 or json[0] != '{':
+    return
+
+  let raw = json.fromJson(GraphFollowers)
+  for instruction in raw.data.user.result.timeline.timeline.instructions:
+    if instruction.kind == "TimelineAddEntries":
+      for entry in instruction.entries:
+        case entry.content.entryType
+        of TimelineTimelineItem:
+          let userResult = entry.content.itemContent.userResults.result
+          if userResult.restId.len > 0:
+            result.content.add parseUserResult(userResult)
+        of TimelineTimelineCursor:
+          if entry.content.cursorType == "Bottom":
+            result.bottom = entry.content.value

+ 17 - 0
src/experimental/types/graphfollowers.nim

@@ -0,0 +1,17 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+import graphlistmembers
+
+type
+  GraphFollowers* = object
+    data*: tuple[user: UserWrapper]
+
+  UserWrapper = object
+    result*: UserResultWrapper
+
+  UserResultWrapper = object
+    timeline*: tuple[timeline: graphlistmembers.Timeline]
+
+# Hook to normalize snake_case field from API to camelCase used by shared types
+proc renameHook*(v: var Content; fieldName: var string) =
+  if fieldName == "user_results":
+    fieldName = "userResults"

+ 3 - 3
src/experimental/types/graphlistmembers.nim

@@ -7,10 +7,10 @@ type
   List = object
     membersTimeline*: tuple[timeline: Timeline]
 
-  Timeline = object
+  Timeline* = object
     instructions*: seq[Instruction]
 
-  Instruction = object
+  Instruction* = object
     kind*: string
     entries*: seq[tuple[content: Content]]
 
@@ -18,7 +18,7 @@ type
     TimelineTimelineItem
     TimelineTimelineCursor
 
-  Content = object
+  Content* = object
     case entryType*: ContentEntryType
     of TimelineTimelineItem:
       itemContent*: tuple[userResults: UserData]

+ 33 - 0
src/routes/timeline.nim

@@ -135,6 +135,39 @@ proc createTimelineRouter*(cfg: Config) =
       resp renderMain(aboutHtml, request, cfg, prefs,
                       "About @" & info.username)
 
+    get "/@name/@kind/?":
+      cond @"kind" in ["followers", "following"]
+      cond '.' notin @"name"
+      cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_'})
+      let
+        prefs = requestPrefs()
+        name = @"name"
+        userId = await getUserId(name)
+
+      if userId.len == 0:
+        resp Http404, showError("User \"" & name & "\" not found", cfg)
+      if userId == "suspended":
+        resp showError(getSuspended(name), cfg)
+
+      let
+        cursor = getCursor()
+        isFollowers = @"kind" == "followers"
+        user = await getCachedUser(name)
+        results = if isFollowers: await getGraphFollowers(userId, cursor)
+                  else: await getGraphFollowing(userId, cursor)
+
+      if user.protected:
+        resp renderMain(renderProtected(user.username), request, cfg, prefs,
+                        "Protected account @" & name)
+
+      let
+        tab = if isFollowers: "Followers" else: "Following"
+        title = (if isFollowers: "People following @" else: "People followed by @") & name
+        html = renderUserList(user, results, prefs, request.path, tab)
+
+      resp renderMain(html, request, cfg, prefs, title,
+                      images = @[user.getUserPic("_400x400")])
+
     get "/@name/?@tab?/?":
       cond '.' notin @"name"
       cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"]

+ 2 - 1
src/types.nim

@@ -61,6 +61,7 @@ type
     rateLimited = 88
     expiredToken = 89
     listIdOrSlug = 112
+    timelineUnavailable = 131
     tweetNotFound = 144
     tweetNotAuthorized = 179
     forbidden = 200
@@ -173,7 +174,7 @@ type
     variants*: seq[VideoVariant]
 
   QueryKind* = enum
-    posts, replies, media, users, tweets, userList
+    posts, replies, media, users, tweets, userList, followers, following
 
   Query* = object
     kind*: QueryKind

+ 39 - 4
src/views/profile.nim

@@ -2,7 +2,7 @@
 import strutils, strformat
 import karax/[karaxdsl, vdom, vstyles]
 
-import renderutils, search
+import renderutils, search, timeline
 import ".."/[types, utils, formatters]
 
 proc renderStat(num: int; class: string; text=""): VNode =
@@ -12,6 +12,13 @@ proc renderStat(num: int; class: string; text=""): VNode =
     span(class="profile-stat-num"):
       text insertSep($num, ',')
 
+proc renderStatLink(num: int; class, href: string): VNode =
+  buildHtml(li(class=class)):
+    a(href=href):
+      span(class="profile-stat-header"): text capitalizeAscii(class)
+      span(class="profile-stat-num"):
+        text insertSep($num, ',')
+
 proc renderUserCard*(user: User; prefs: Prefs; info: AccountInfo): VNode =
   buildHtml(tdiv(class="profile-card")):
     tdiv(class="profile-card-info"):
@@ -65,8 +72,8 @@ proc renderUserCard*(user: User; prefs: Prefs; info: AccountInfo): VNode =
       tdiv(class="profile-card-extra-links"):
         ul(class="profile-statlist"):
           renderStat(user.tweets, "posts", text="Tweets")
-          renderStat(user.following, "following")
-          renderStat(user.followers, "followers")
+          renderStatLink(user.following, "following", &"/{user.username}/following")
+          renderStatLink(user.followers, "followers", &"/{user.username}/followers")
           renderStat(user.likes, "likes")
 
 proc renderPhotoRail(profile: Profile): VNode =
@@ -99,7 +106,7 @@ proc renderBanner(banner: string): VNode =
     else:
       a(href=getPicUrl(banner), target="_blank"): genImg(banner)
 
-proc renderProtected(username: string): VNode =
+proc renderProtected*(username: string): VNode =
   buildHtml(tdiv(class="timeline-container")):
     tdiv(class="timeline-header timeline-protected"):
       h2: text "This account's tweets are protected."
@@ -128,3 +135,31 @@ proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode =
       renderProtected(profile.user.username)
     else:
       renderTweetSearch(profile.tweets, prefs, path, profile.pinned)
+
+proc renderFollowTabs(user: User; activeTab: string): VNode =
+  buildHtml(ul(class="tab")):
+    for tab in ["Following", "Followers"]:
+      li(class=(if activeTab == tab: "tab-item active" else: "tab-item")):
+        a(href=(&"/{user.username}/{tab.toLowerAscii()}")): text tab
+
+proc renderUserList*(user: User; results: Result[User]; prefs: Prefs;
+                     path, activeTab: string): VNode =
+  # Check if we've loaded all results (for page 1)
+  var displayResults = results
+  if results.beginning:
+    let expectedCount = if activeTab == "Followers": user.followers else: user.following
+    if results.content.len >= expectedCount:
+      displayResults.bottom = ""
+
+  buildHtml(tdiv(class="profile-tabs")):
+    if not prefs.hideBanner:
+      tdiv(class="profile-banner"):
+        renderBanner(user.banner)
+
+    let sticky = if prefs.stickyProfile: " sticky" else: ""
+    tdiv(class=("profile-tab" & sticky)):
+      renderUserCard(user, prefs, AccountInfo())
+
+    tdiv(class="timeline-container"):
+      renderFollowTabs(user, activeTab)
+      renderTimelineUsers(displayResults, prefs, path)

+ 43 - 0
tests/test_followers.py

@@ -0,0 +1,43 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+from base import BaseTestCase, Timeline
+
+
+class FollowersTest(BaseTestCase):
+    """Tests for followers and following pages"""
+
+    def test_followers_page_loads(self):
+        """Test that followers page loads and shows users"""
+        self.open_nitter('jack/followers')
+        self.assert_title_contains('following @jack')
+        # Check for user list
+        self.assert_element('.timeline-item')
+
+    def test_following_page_loads(self):
+        """Test that following page loads and shows users"""
+        self.open_nitter('jack/following')
+        self.assert_title_contains('followed by @jack')
+        # Check for user list
+        self.assert_element('.timeline-item')
+
+    def test_followers_has_navigation_tabs(self):
+        """Test that followers page has following/followers tabs"""
+        self.open_nitter('jack/followers')
+        self.assert_element('.profile-statlist')
+        # Check for Following and Followers links
+        self.assert_element('a[href="/jack/following"]')
+        self.assert_element('a[href="/jack/followers"]')
+
+    def test_following_pagination(self):
+        """Test that following page has load more button or scroll-to-top"""
+        self.open_nitter('jack/following')
+        # Should have either more button or top-ref (scroll to top arrow)
+        try:
+            self.assert_element('.show-more')
+        except:
+            self.assert_element('.top-ref')
+
+    def test_nonexistent_user_followers(self):
+        """Test 404 for non-existent user"""
+        self.open_nitter('this_user_definitely_does_not_exist_12345/followers')
+        self.assert_element('.error-panel')
+        self.assert_text_visible('not found')