diff --git a/src/api.nim b/src/api.nim index f2085df..f73ef10 100644 --- a/src/api.nim +++ b/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 diff --git a/src/consts.nim b/src/consts.nim index 8ec4f04..899a06c 100644 --- a/src/consts.nim +++ b/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, diff --git a/src/experimental/parser/graphql.nim b/src/experimental/parser/graphql.nim index 65ebc3d..0a7484c 100644 --- a/src/experimental/parser/graphql.nim +++ b/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 diff --git a/src/experimental/types/graphfollowers.nim b/src/experimental/types/graphfollowers.nim new file mode 100644 index 0000000..ba9210b --- /dev/null +++ b/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" diff --git a/src/experimental/types/graphlistmembers.nim b/src/experimental/types/graphlistmembers.nim index 4cb3757..9e520d8 100644 --- a/src/experimental/types/graphlistmembers.nim +++ b/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] diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index 0eb48e2..6e8969e 100644 --- a/src/routes/timeline.nim +++ b/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"] diff --git a/src/types.nim b/src/types.nim index f51f349..2ccad10 100644 --- a/src/types.nim +++ b/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 diff --git a/src/views/profile.nim b/src/views/profile.nim index 5b751d6..de09368 100644 --- a/src/views/profile.nim +++ b/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) diff --git a/tests/test_followers.py b/tests/test_followers.py new file mode 100644 index 0000000..ccc103b --- /dev/null +++ b/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')